diff --git a/x-pack/platform/plugins/shared/alerting/common/constants/gap_auto_fill_scheduler.ts b/x-pack/platform/plugins/shared/alerting/common/constants/gap_auto_fill_scheduler.ts index c5456e5db5cae..85b62c7e4e934 100644 --- a/x-pack/platform/plugins/shared/alerting/common/constants/gap_auto_fill_scheduler.ts +++ b/x-pack/platform/plugins/shared/alerting/common/constants/gap_auto_fill_scheduler.ts @@ -28,3 +28,12 @@ export const gapAutoFillSchedulerLimits = { }, minScheduleIntervalInMs: 60 * 1000, } as const; + +export const GAP_AUTO_FILL_STATUS = { + SUCCESS: 'success', + ERROR: 'error', + SKIPPED: 'skipped', + NO_GAPS: 'no_gaps', +} as const; + +export type GapAutoFillStatus = (typeof GAP_AUTO_FILL_STATUS)[keyof typeof GAP_AUTO_FILL_STATUS]; diff --git a/x-pack/platform/plugins/shared/alerting/common/constants/index.ts b/x-pack/platform/plugins/shared/alerting/common/constants/index.ts index a13f3934e8dcf..50e5dfa4c25f3 100644 --- a/x-pack/platform/plugins/shared/alerting/common/constants/index.ts +++ b/x-pack/platform/plugins/shared/alerting/common/constants/index.ts @@ -19,3 +19,5 @@ export { alertDeleteCategoryIds } from './alert_delete'; export type { BackfillInitiator } from './backfill'; export { backfillInitiator } from './backfill'; export { gapAutoFillSchedulerLimits } from './gap_auto_fill_scheduler'; +export { GAP_AUTO_FILL_STATUS } from './gap_auto_fill_scheduler'; +export type { GapAutoFillStatus } from './gap_auto_fill_scheduler'; diff --git a/x-pack/platform/plugins/shared/alerting/common/index.ts b/x-pack/platform/plugins/shared/alerting/common/index.ts index 39d3b82680225..ea72cd5641ca7 100644 --- a/x-pack/platform/plugins/shared/alerting/common/index.ts +++ b/x-pack/platform/plugins/shared/alerting/common/index.ts @@ -180,7 +180,8 @@ export { systemConnectorActionRefPrefix, } from './action_ref_prefix'; export { gapStatus } from './constants'; - +export { GAP_AUTO_FILL_STATUS } from './constants'; +export type { GapAutoFillStatus } from './constants'; export { mappingFromFieldMap, getComponentTemplateFromFieldMap, diff --git a/x-pack/platform/plugins/shared/alerting/common/routes/gaps/apis/gap_auto_fill_scheduler/index.ts b/x-pack/platform/plugins/shared/alerting/common/routes/gaps/apis/gap_auto_fill_scheduler/index.ts index 8cc10e4fa1a02..3256064e5ae46 100644 --- a/x-pack/platform/plugins/shared/alerting/common/routes/gaps/apis/gap_auto_fill_scheduler/index.ts +++ b/x-pack/platform/plugins/shared/alerting/common/routes/gaps/apis/gap_auto_fill_scheduler/index.ts @@ -7,22 +7,44 @@ export { gapAutoFillSchedulerBodySchema, + gapAutoFillSchedulerUpdateBodySchema, gapAutoFillSchedulerResponseSchema, + getGapAutoFillSchedulerParamsSchema, + gapAutoFillSchedulerLogEntrySchema, + gapAutoFillSchedulerLogsResponseSchema, + gapAutoFillSchedulerLogsRequestQuerySchema, } from './schemas/latest'; export type { GapAutoFillSchedulerRequestBody, GapAutoFillSchedulerResponseBody, GapAutoFillSchedulerResponse, + UpdateGapAutoFillSchedulerRequestBody, UpdateGapAutoFillSchedulerResponse, + GetGapAutoFillSchedulerParams, + GapAutoFillSchedulerLogEntry, + GapAutoFillSchedulerLogsResponseBody, + GapAutoFillSchedulerLogsResponse, + GapAutoFillSchedulerLogsRequestQuery, } from './types/latest'; export { gapAutoFillSchedulerBodySchema as gapAutoFillSchedulerBodySchemaV1, + gapAutoFillSchedulerUpdateBodySchema as gapAutoFillSchedulerUpdateBodySchemaV1, gapAutoFillSchedulerResponseSchema as gapAutoFillSchedulerResponseSchemaV1, + getGapAutoFillSchedulerParamsSchema as getGapAutoFillSchedulerParamsSchemaV1, + gapAutoFillSchedulerLogEntrySchema as gapAutoFillSchedulerLogEntrySchemaV1, + gapAutoFillSchedulerLogsResponseSchema as gapAutoFillSchedulerLogsResponseSchemaV1, + gapAutoFillSchedulerLogsRequestQuerySchema as gapAutoFillSchedulerLogsRequestQuerySchemaV1, } from './schemas/v1'; export type { GapAutoFillSchedulerRequestBody as GapAutoFillSchedulerRequestBodyV1, GapAutoFillSchedulerResponseBody as GapAutoFillSchedulerResponseBodyV1, GapAutoFillSchedulerResponse as GapAutoFillSchedulerResponseV1, + UpdateGapAutoFillSchedulerRequestBody as UpdateGapAutoFillSchedulerRequestBodyV1, UpdateGapAutoFillSchedulerResponse as UpdateGapAutoFillSchedulerResponseV1, + GetGapAutoFillSchedulerParams as GetGapAutoFillSchedulerParamsV1, + GapAutoFillSchedulerLogEntry as GapAutoFillSchedulerLogEntryV1, + GapAutoFillSchedulerLogsResponseBody as GapAutoFillSchedulerLogsResponseBodyV1, + GapAutoFillSchedulerLogsResponse as GapAutoFillSchedulerLogsResponseV1, + GapAutoFillSchedulerLogsRequestQuery as GapAutoFillSchedulerLogsRequestQueryV1, } from './types/v1'; diff --git a/x-pack/platform/plugins/shared/alerting/common/routes/gaps/apis/gap_auto_fill_scheduler/schemas/v1.ts b/x-pack/platform/plugins/shared/alerting/common/routes/gaps/apis/gap_auto_fill_scheduler/schemas/v1.ts index 90ae7ff1fb25b..19da02684ed67 100644 --- a/x-pack/platform/plugins/shared/alerting/common/routes/gaps/apis/gap_auto_fill_scheduler/schemas/v1.ts +++ b/x-pack/platform/plugins/shared/alerting/common/routes/gaps/apis/gap_auto_fill_scheduler/schemas/v1.ts @@ -14,6 +14,51 @@ import { parseDuration } from '../../../../../parse_duration'; const { maxBackfills, numRetries, minScheduleIntervalInMs } = gapAutoFillSchedulerLimits; +const validateGapAutoFillSchedulerPayload = ( + gapFillRange: string, + schedule: { interval: string }, + ruleTypes: { type: string; consumer: string }[] +) => { + const now = new Date(); + const parsed = dateMath.parse(gapFillRange, { forceNow: now }); + if (!parsed || !parsed.isValid()) { + return 'gap_fill_range is invalid'; + } + + const maxLookbackExpression = `now-${MAX_SCHEDULE_BACKFILL_LOOKBACK_WINDOW_DAYS}d`; + const lookbackLimit = dateMath.parse(maxLookbackExpression, { forceNow: now }); + if (!lookbackLimit || !lookbackLimit.isValid()) { + return 'gap_fill_range is invalid'; + } + + if (parsed.isBefore(lookbackLimit)) { + return `gap_fill_range cannot look back more than ${MAX_SCHEDULE_BACKFILL_LOOKBACK_WINDOW_DAYS} days`; + } + + try { + const intervalMs = parseDuration(schedule.interval); + if (intervalMs < minScheduleIntervalInMs) { + return 'schedule.interval must be at least 1 minute'; + } + } catch (error) { + return `schedule.interval is invalid: ${(error as Error).message}`; + } + + // Duplicate check for rule_types + const seen = new Set(); + for (const ruleType of ruleTypes) { + const key = `${ruleType.type}:${ruleType.consumer}`; + if (seen.has(key)) { + return `rule_types contains duplicate entry: type="${ruleType.type}" consumer="${ruleType.consumer}"`; + } + seen.add(key); + } +}; + +export const getGapAutoFillSchedulerParamsSchema = schema.object({ + id: schema.string(), +}); + export const gapAutoFillSchedulerBodySchema = schema.object( { id: schema.maybe(schema.string()), @@ -34,41 +79,41 @@ export const gapAutoFillSchedulerBodySchema = schema.object( ), }, { - validate({ gap_fill_range: gapFillRange, schedule, rule_types: ruleTypes }) { - const now = new Date(); - const parsed = dateMath.parse(gapFillRange, { forceNow: now }); - if (!parsed || !parsed.isValid()) { - return 'gap_fill_range is invalid'; - } - - const maxLookbackExpression = `now-${MAX_SCHEDULE_BACKFILL_LOOKBACK_WINDOW_DAYS}d`; - const lookbackLimit = dateMath.parse(maxLookbackExpression, { forceNow: now }); - if (!lookbackLimit || !lookbackLimit.isValid()) { - return 'gap_fill_range is invalid'; - } - - if (parsed.isBefore(lookbackLimit)) { - return `gap_fill_range cannot look back more than ${MAX_SCHEDULE_BACKFILL_LOOKBACK_WINDOW_DAYS} days`; - } - - try { - const intervalMs = parseDuration(schedule.interval); - if (intervalMs < minScheduleIntervalInMs) { - return 'schedule.interval must be at least 1 minute'; - } - } catch (error) { - return `schedule.interval is invalid: ${(error as Error).message}`; - } + validate(payload) { + return validateGapAutoFillSchedulerPayload( + payload.gap_fill_range, + payload.schedule, + payload.rule_types + ); + }, + } +); - // Duplicate check for rule_types - const seen = new Set(); - for (const ruleType of ruleTypes) { - const key = `${ruleType.type}:${ruleType.consumer}`; - if (seen.has(key)) { - return `rule_types contains duplicate entry: type="${ruleType.type}" consumer="${ruleType.consumer}"`; - } - seen.add(key); - } +export const gapAutoFillSchedulerUpdateBodySchema = schema.object( + { + name: schema.string(), + enabled: schema.boolean(), + gap_fill_range: schema.string(), + max_backfills: schema.number({ min: 1, max: 5000 }), + num_retries: schema.number({ min: 1 }), + schedule: schema.object({ + interval: schema.string(), + }), + scope: schema.arrayOf(schema.string()), + rule_types: schema.arrayOf( + schema.object({ + type: schema.string(), + consumer: schema.string(), + }) + ), + }, + { + validate(payload) { + return validateGapAutoFillSchedulerPayload( + payload.gap_fill_range, + payload.schedule, + payload.rule_types + ); }, } ); @@ -95,3 +140,48 @@ export const gapAutoFillSchedulerResponseSchema = schema.object({ created_at: schema.string(), updated_at: schema.string(), }); + +export const gapAutoFillSchedulerLogsRequestQuerySchema = schema.object({ + start: schema.string(), + end: schema.string(), + page: schema.number({ defaultValue: 1, min: 1 }), + per_page: schema.number({ defaultValue: 50, min: 1, max: 1000 }), + sort_field: schema.oneOf([schema.literal('@timestamp')], { defaultValue: '@timestamp' }), + sort_direction: schema.oneOf([schema.literal('asc'), schema.literal('desc')], { + defaultValue: 'desc', + }), + statuses: schema.maybe( + schema.arrayOf( + schema.oneOf([ + schema.literal('success'), + schema.literal('error'), + schema.literal('skipped'), + schema.literal('no_gaps'), + ]) + ) + ), +}); + +export const gapAutoFillSchedulerLogEntrySchema = schema.object({ + id: schema.string(), + timestamp: schema.maybe(schema.string()), + status: schema.maybe(schema.string()), + message: schema.maybe(schema.string()), + results: schema.maybe( + schema.arrayOf( + schema.object({ + rule_id: schema.maybe(schema.string()), + processed_gaps: schema.maybe(schema.number()), + status: schema.maybe(schema.string()), + error: schema.maybe(schema.string()), + }) + ) + ), +}); + +export const gapAutoFillSchedulerLogsResponseSchema = schema.object({ + data: schema.arrayOf(gapAutoFillSchedulerLogEntrySchema), + total: schema.number(), + page: schema.number(), + per_page: schema.number(), +}); diff --git a/x-pack/platform/plugins/shared/alerting/common/routes/gaps/apis/gap_auto_fill_scheduler/types/v1.ts b/x-pack/platform/plugins/shared/alerting/common/routes/gaps/apis/gap_auto_fill_scheduler/types/v1.ts index 5255aae33ff75..d01e5bc0a45b5 100644 --- a/x-pack/platform/plugins/shared/alerting/common/routes/gaps/apis/gap_auto_fill_scheduler/types/v1.ts +++ b/x-pack/platform/plugins/shared/alerting/common/routes/gaps/apis/gap_auto_fill_scheduler/types/v1.ts @@ -6,11 +6,22 @@ */ import type { TypeOf } from '@kbn/config-schema'; -import type { gapAutoFillSchedulerBodySchemaV1, gapAutoFillSchedulerResponseSchemaV1 } from '..'; +import type { + gapAutoFillSchedulerBodySchemaV1, + gapAutoFillSchedulerResponseSchemaV1, + gapAutoFillSchedulerUpdateBodySchemaV1, + getGapAutoFillSchedulerParamsSchemaV1, + gapAutoFillSchedulerLogEntrySchemaV1, + gapAutoFillSchedulerLogsResponseSchemaV1, + gapAutoFillSchedulerLogsRequestQuerySchemaV1, +} from '..'; export type GapAutoFillSchedulerRequestBody = TypeOf; +export type UpdateGapAutoFillSchedulerRequestBody = TypeOf< + typeof gapAutoFillSchedulerUpdateBodySchemaV1 +>; export type GapAutoFillSchedulerResponseBody = TypeOf; - +export type GetGapAutoFillSchedulerParams = TypeOf; export interface GapAutoFillSchedulerResponse { body: GapAutoFillSchedulerResponseBody; } @@ -18,3 +29,15 @@ export interface GapAutoFillSchedulerResponse { export interface UpdateGapAutoFillSchedulerResponse { body: GapAutoFillSchedulerResponseBody; } + +export type GapAutoFillSchedulerLogEntry = TypeOf; +export type GapAutoFillSchedulerLogsResponseBody = TypeOf< + typeof gapAutoFillSchedulerLogsResponseSchemaV1 +>; +export interface GapAutoFillSchedulerLogsResponse { + body: GapAutoFillSchedulerLogsResponseBody; +} + +export type GapAutoFillSchedulerLogsRequestQuery = TypeOf< + typeof gapAutoFillSchedulerLogsRequestQuerySchemaV1 +>; diff --git a/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/create/create_gap_auto_fill_scheduler.ts b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/create/create_gap_auto_fill_scheduler.ts index ebdf2f0969d41..d6b345d00809d 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/create/create_gap_auto_fill_scheduler.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/create/create_gap_auto_fill_scheduler.ts @@ -112,35 +112,37 @@ Payload summary: ${JSON.stringify(otherParams, (key, value) => savedObjectOptions ); - try { - await taskManager.ensureScheduled( - { - id: so.id, - taskType: GAP_AUTO_FILL_SCHEDULER_TASK_TYPE, - schedule: params.schedule, - scope: params.scope ?? [], - params: { - configId: so.id, - spaceId: context.spaceId, - }, - state: {}, - }, - { - request: params.request, - } - ); - } catch (e) { - context.logger.error( - `Failed to schedule task for gap auto fill scheduler ${so.id}. Will attempt to delete the saved object.` - ); + if (params.enabled) { try { - await soClient.delete(GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, so.id); - } catch (deleteError) { + await taskManager.ensureScheduled( + { + id: so.id, + taskType: GAP_AUTO_FILL_SCHEDULER_TASK_TYPE, + schedule: params.schedule, + scope: params.scope ?? [], + params: { + configId: so.id, + spaceId: context.spaceId, + }, + state: {}, + }, + { + request: params.request, + } + ); + } catch (e) { context.logger.error( - `Failed to delete gap auto fill saved object for gap auto fill scheduler ${so.id}.` + `Failed to schedule task for gap auto fill scheduler ${so.id}. Will attempt to delete the saved object.` ); + try { + await soClient.delete(GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, so.id); + } catch (deleteError) { + context.logger.error( + `Failed to delete gap auto fill saved object for gap auto fill scheduler ${so.id}.` + ); + } + throw e; } - throw e; } // Log successful creation diff --git a/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/delete/delete_gap_auto_fill_scheduler.test.ts b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/delete/delete_gap_auto_fill_scheduler.test.ts new file mode 100644 index 0000000000000..d9d4e5f844c58 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/delete/delete_gap_auto_fill_scheduler.test.ts @@ -0,0 +1,216 @@ +/* + * 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 { ActionsAuthorization } from '@kbn/actions-plugin/server'; +import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks'; +import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; +import { + savedObjectsClientMock, + savedObjectsRepositoryMock, +} from '@kbn/core-saved-objects-api-server-mocks'; +import { uiSettingsServiceMock } from '@kbn/core-ui-settings-server-mocks'; +import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; +import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; +import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; +import { actionsClientMock } from '@kbn/actions-plugin/server/mocks'; +import type { SavedObject, Logger } from '@kbn/core/server'; +import { eventLoggerMock } from '@kbn/event-log-plugin/server/event_logger.mock'; +import { eventLogClientMock } from '@kbn/event-log-plugin/server/event_log_client.mock'; +import type { AlertingAuthorization } from '../../../../authorization'; +import { alertingAuthorizationMock } from '../../../../authorization/alerting_authorization.mock'; +import { ruleTypeRegistryMock } from '../../../../rule_type_registry.mock'; +import type { ConstructorOptions } from '../../../../rules_client'; +import { RulesClient } from '../../../../rules_client'; +import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry'; +import { GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; +import type { GapAutoFillSchedulerSO } from '../../../../data/gap_auto_fill_scheduler/types/gap_auto_fill_scheduler'; +import { backfillClientMock } from '../../../../backfill_client/backfill_client.mock'; + +describe('deleteGapAutoFillScheduler()', () => { + const kibanaVersion = 'v8.0.0'; + const taskManager = taskManagerMock.createStart(); + const ruleTypeRegistry = ruleTypeRegistryMock.create(); + const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); + const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); + const authorization = alertingAuthorizationMock.create(); + const actionsAuthorization = actionsAuthorizationMock.create(); + const auditLogger = auditLoggerMock.create(); + const internalSavedObjectsRepository = savedObjectsRepositoryMock.create(); + const logger: Logger = loggingSystemMock.create().get(); + + const actionsClient = actionsClientMock.create(); + const eventLogger = eventLoggerMock.create(); + const eventLogClient = eventLogClientMock.create(); + + const rulesClientParamsBase: jest.Mocked = { + taskManager, + ruleTypeRegistry, + unsecuredSavedObjectsClient, + authorization: authorization as unknown as AlertingAuthorization, + actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, + spaceId: 'default', + namespace: 'default', + getUserName: jest.fn().mockResolvedValue('elastic'), + createAPIKey: jest.fn(), + logger, + internalSavedObjectsRepository, + encryptedSavedObjectsClient: encryptedSavedObjects, + getActionsClient: jest.fn(), + getEventLogClient: jest.fn(), + kibanaVersion, + auditLogger, + maxScheduledPerMinute: 10000, + minimumScheduleInterval: { value: '1m', enforce: false }, + isAuthenticationTypeAPIKey: jest.fn(), + getAuthenticationAPIKey: jest.fn(), + getAlertIndicesAlias: jest.fn(), + alertsService: null, + backfillClient: backfillClientMock.create(), + isSystemAction: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), + uiSettings: uiSettingsServiceMock.createStartContract(), + eventLogger, + }; + + function setupSchedulerSo(attrs?: Partial) { + const so: SavedObject = { + id: 'scheduler-1', + type: GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, + attributes: { + id: 'scheduler-1', + name: 'auto-fill', + enabled: true, + schedule: { interval: '1h' }, + gapFillRange: 'now-1d', + maxBackfills: 100, + numRetries: 3, + scope: ['test-space'], + ruleTypes: [{ type: 'test-rule-type', consumer: 'test-consumer' }], + ruleTypeConsumerPairs: ['test-rule-type:test-consumer'], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + createdBy: 'elastic', + updatedBy: 'elastic', + ...attrs, + }, + references: [], + }; + unsecuredSavedObjectsClient.get.mockResolvedValue(so); + return so; + } + + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('removes task, deletes SO, deletes backfills and audits success', async () => { + rulesClientParamsBase.getEventLogClient.mockResolvedValue(eventLogClient); + rulesClientParamsBase.getActionsClient.mockResolvedValue(actionsClient); + const rulesClient = new RulesClient(rulesClientParamsBase); + + const so = setupSchedulerSo(); + + const params = { id: so.id }; + await expect(rulesClient.deleteGapAutoFillScheduler(params)).resolves.toBeUndefined(); + + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledWith( + GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, + params.id + ); + + // scheduledTaskId is the SO id for this scheduler + expect(taskManager.removeIfExists).toHaveBeenCalledWith(so.id); + + expect(unsecuredSavedObjectsClient.delete).toHaveBeenCalledWith( + GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, + params.id + ); + + expect(rulesClientParamsBase.getEventLogClient).toHaveBeenCalled(); + expect(rulesClientParamsBase.getActionsClient).toHaveBeenCalled(); + + // backfills are deleted and gaps updated + const backfillClientInstance = rulesClient.getContext().backfillClient; + expect(backfillClientInstance.deleteBackfillsByInitiatorId).toHaveBeenCalledWith( + expect.objectContaining({ + initiatorId: so.id, + unsecuredSavedObjectsClient, + shouldUpdateGaps: true, + internalSavedObjectsRepository, + eventLogClient, + eventLogger, + actionsClient, + }) + ); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ action: 'gap_auto_fill_scheduler_delete' }), + kibana: expect.objectContaining({ + saved_object: expect.objectContaining({ + type: GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, + id: params.id, + name: so.attributes.name, + }), + }), + }) + ); + }); + + test('validates params and throws on invalid', async () => { + const rulesClient = new RulesClient({ + ...rulesClientParamsBase, + backfillClient: backfillClientMock.create(), + }); + + const invalidParams = { id: 123 as unknown as string }; + await expect( + rulesClient.deleteGapAutoFillScheduler( + invalidParams as unknown as import('../get/types').GetGapAutoFillSchedulerParams + ) + ).rejects.toThrow(/Error validating gap auto fill scheduler delete parameters/); + expect(auditLogger.log).not.toHaveBeenCalled(); + }); + + test('logs and rethrows when authorization fails', async () => { + const rulesClient = new RulesClient({ + ...rulesClientParamsBase, + backfillClient: backfillClientMock.create(), + }); + + setupSchedulerSo(); + (authorization.ensureAuthorized as jest.Mock).mockImplementationOnce(() => { + throw new Error('no access'); + }); + + await expect(rulesClient.deleteGapAutoFillScheduler({ id: 'scheduler-1' })).rejects.toThrow( + 'no access' + ); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ action: 'gap_auto_fill_scheduler_delete' }), + error: expect.any(Object), + }) + ); + }); + + test('wraps unexpected errors and logs them', async () => { + const rulesClient = new RulesClient({ + ...rulesClientParamsBase, + backfillClient: backfillClientMock.create(), + }); + + unsecuredSavedObjectsClient.get.mockRejectedValueOnce(new Error('not found')); + await expect(rulesClient.deleteGapAutoFillScheduler({ id: 'unknown' })).rejects.toThrow( + /Failed to delete gap auto fill scheduler by id: unknown/ + ); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to delete gap auto fill scheduler by id: unknown') + ); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/delete/delete_gap_auto_fill_scheduler.ts b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/delete/delete_gap_auto_fill_scheduler.ts new file mode 100644 index 0000000000000..8ea851fe400f2 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/delete/delete_gap_auto_fill_scheduler.ts @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Boom from '@hapi/boom'; +import type { RulesClientContext } from '../../../../rules_client/types'; +import { GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; +import { WriteOperations, AlertingAuthorizationEntity } from '../../../../authorization'; +import { + gapAutoFillSchedulerAuditEvent, + GapAutoFillSchedulerAuditAction, +} from '../../../../rules_client/common/audit_events'; +import type { GapAutoFillSchedulerSO } from '../../../../data/gap_auto_fill_scheduler/types/gap_auto_fill_scheduler'; +import { getGapAutoFillSchedulerSchema } from '../get/schemas'; +import type { GetGapAutoFillSchedulerParams } from '../get/types'; + +export async function deleteGapAutoFillScheduler( + context: RulesClientContext, + params: GetGapAutoFillSchedulerParams +): Promise { + try { + getGapAutoFillSchedulerSchema.validate(params); + } catch (error) { + throw Boom.badRequest( + `Error validating gap auto fill scheduler delete parameters "${JSON.stringify(params)}" - ${ + (error as Error).message + }` + ); + } + + const soClient = context.unsecuredSavedObjectsClient; + const taskManager = context.taskManager; + + try { + const schedulerSo = await soClient.get( + GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, + params.id + ); + + const schedulerName = schedulerSo.attributes?.name ?? params.id; + const ruleTypes = schedulerSo.attributes?.ruleTypes ?? []; + try { + for (const ruleType of ruleTypes) { + await context.authorization.ensureAuthorized({ + ruleTypeId: ruleType.type, + consumer: ruleType.consumer, + operation: WriteOperations.DeleteGapAutoFillScheduler, + entity: AlertingAuthorizationEntity.Rule, + }); + } + } catch (authError) { + context.auditLogger?.log( + gapAutoFillSchedulerAuditEvent({ + action: GapAutoFillSchedulerAuditAction.DELETE, + savedObject: { + type: GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, + id: params.id, + name: schedulerName, + }, + error: authError as Error, + }) + ); + throw authError; + } + + const scheduledTaskId = schedulerSo.id; + + await taskManager.removeIfExists(scheduledTaskId); + + await soClient.delete(GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, params.id); + + await context.backfillClient.deleteBackfillsByInitiatorId({ + initiatorId: scheduledTaskId, + unsecuredSavedObjectsClient: soClient, + shouldUpdateGaps: true, + internalSavedObjectsRepository: context.internalSavedObjectsRepository, + eventLogClient: await context.getEventLogClient(), + eventLogger: context.eventLogger, + actionsClient: await context.getActionsClient(), + }); + + context.auditLogger?.log( + gapAutoFillSchedulerAuditEvent({ + action: GapAutoFillSchedulerAuditAction.DELETE, + savedObject: { + type: GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, + id: params.id, + name: schedulerName, + }, + }) + ); + } catch (err) { + const errorMessage = `Failed to delete gap auto fill scheduler by id: ${params.id}`; + context.logger.error(`${errorMessage} - ${err}`); + throw Boom.boomify(err as Error, { message: errorMessage }); + } +} diff --git a/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get/get_gap_auto_fill_scheduler.test.ts b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get/get_gap_auto_fill_scheduler.test.ts new file mode 100644 index 0000000000000..539861325c2d9 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get/get_gap_auto_fill_scheduler.test.ts @@ -0,0 +1,184 @@ +/* + * 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 { ActionsAuthorization } from '@kbn/actions-plugin/server'; +import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks'; +import type { AlertingAuthorization } from '../../../../authorization'; +import { alertingAuthorizationMock } from '../../../../authorization/alerting_authorization.mock'; +import { ruleTypeRegistryMock } from '../../../../rule_type_registry.mock'; +import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; +import { + savedObjectsClientMock, + savedObjectsRepositoryMock, +} from '@kbn/core-saved-objects-api-server-mocks'; +import { uiSettingsServiceMock } from '@kbn/core-ui-settings-server-mocks'; +import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; +import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; +import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; +import { RulesClient } from '../../../../rules_client'; +import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry'; +import { GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; +import type { SavedObject } from '@kbn/core/server'; +import type { GapAutoFillSchedulerSO } from '../../../../data/gap_auto_fill_scheduler/types/gap_auto_fill_scheduler'; +import { transformSavedObjectToGapAutoFillSchedulerResult } from '../../transforms'; + +const kibanaVersion = 'v8.0.0'; +const taskManager = taskManagerMock.createStart(); +const ruleTypeRegistry = ruleTypeRegistryMock.create(); +const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); +const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); +const authorization = alertingAuthorizationMock.create(); +const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditLoggerMock.create(); +const internalSavedObjectsRepository = savedObjectsRepositoryMock.create(); + +describe('getGapFillAutoScheduler()', () => { + let rulesClient: RulesClient; + + beforeEach(() => { + jest.resetAllMocks(); + rulesClient = new RulesClient({ + taskManager, + ruleTypeRegistry, + unsecuredSavedObjectsClient, + authorization: authorization as unknown as AlertingAuthorization, + actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, + spaceId: 'default', + namespace: 'default', + getUserName: jest.fn(), + createAPIKey: jest.fn(), + logger: loggingSystemMock.create().get(), + internalSavedObjectsRepository, + encryptedSavedObjectsClient: encryptedSavedObjects, + getActionsClient: jest.fn(), + getEventLogClient: jest.fn(), + kibanaVersion, + auditLogger, + maxScheduledPerMinute: 10000, + minimumScheduleInterval: { value: '1m', enforce: false }, + isAuthenticationTypeAPIKey: jest.fn(), + getAuthenticationAPIKey: jest.fn(), + getAlertIndicesAlias: jest.fn(), + alertsService: null, + backfillClient: null as unknown as never, + isSystemAction: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), + uiSettings: uiSettingsServiceMock.createStartContract(), + }); + + const so: SavedObject = { + id: 'gap-1', + type: GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, + attributes: { + id: 'gap-1', + name: 'auto-fill', + enabled: true, + schedule: { interval: '1h' }, + gapFillRange: 'now-1d', + maxBackfills: 100, + numRetries: 3, + scope: ['test-space'], + ruleTypes: [ + { type: 'test-rule-type1', consumer: 'test-consumer' }, + { type: 'test-rule-type2', consumer: 'test-consumer' }, + ], + ruleTypeConsumerPairs: ['test-rule-type1:test-consumer', 'test-rule-type2:test-consumer'], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + createdBy: 'elastic', + updatedBy: 'elastic', + }, + references: [], + }; + unsecuredSavedObjectsClient.get.mockResolvedValue(so); + }); + + test('should successfully get gap fill auto scheduler', async () => { + const result = await rulesClient.getGapAutoFillScheduler({ id: 'gap-1' }); + + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledWith( + GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, + 'gap-1' + ); + + expect(authorization.ensureAuthorized).toHaveBeenCalledTimes(2); + expect(auditLogger.log).toHaveBeenCalledTimes(1); + + expect(result).toEqual( + transformSavedObjectToGapAutoFillSchedulerResult({ + savedObject: await unsecuredSavedObjectsClient.get.mock.results[0].value, + }) + ); + }); + + describe('error handling', () => { + test('should propagate saved objects get error', async () => { + unsecuredSavedObjectsClient.get.mockImplementationOnce(() => { + throw new Error('error getting SO!'); + }); + + await expect(rulesClient.getGapAutoFillScheduler({ id: 'gap-1' })).rejects.toThrowError( + 'error getting SO!' + ); + }); + + test('should audit and throw when authorization fails', async () => { + // return a valid SO first, then fail on auth check + const so: SavedObject = { + id: 'gap-1', + type: GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, + attributes: { + id: 'gap-1', + name: 'auto-fill', + enabled: true, + schedule: { interval: '1h' }, + gapFillRange: 'now-1d', + maxBackfills: 100, + numRetries: 3, + scope: ['test-space'], + ruleTypes: [ + { type: 'test-rule-type1', consumer: 'test-consumer' }, + { type: 'test-rule-type2', consumer: 'test-consumer' }, + ], + ruleTypeConsumerPairs: ['test-rule-type1:test-consumer', 'test-rule-type2:test-consumer'], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + createdBy: 'elastic', + updatedBy: 'elastic', + }, + references: [], + }; + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(so); + + (authorization.ensureAuthorized as jest.Mock).mockImplementationOnce(() => { + throw new Error('Unauthorized'); + }); + + await expect(rulesClient.getGapAutoFillScheduler({ id: 'gap-1' })).rejects.toThrowError( + 'Unauthorized' + ); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ error: expect.objectContaining({ message: 'Unauthorized' }) }) + ); + }); + + test('should throw when saved object has error payload', async () => { + const soErrorLike = { + id: 'gap-1', + type: GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, + error: { error: 'err', message: 'Unable to get', statusCode: 404 }, + attributes: { name: 'auto-fill' }, + } as unknown as SavedObject; + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(soErrorLike); + + await expect(rulesClient.getGapAutoFillScheduler({ id: 'gap-1' })).rejects.toThrowError( + 'Unable to get' + ); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get/get_gap_auto_fill_scheduler.ts b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get/get_gap_auto_fill_scheduler.ts new file mode 100644 index 0000000000000..df35717ceff9b --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get/get_gap_auto_fill_scheduler.ts @@ -0,0 +1,105 @@ +/* + * 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 Boom from '@hapi/boom'; +import type { RulesClientContext } from '../../../../rules_client/types'; +import type { GetGapAutoFillSchedulerParams } from './types'; +import type { GapAutoFillSchedulerResponse } from '../../result/types'; +import { getGapAutoFillSchedulerSchema } from './schemas'; +import { transformSavedObjectToGapAutoFillSchedulerResult } from '../../transforms'; +import type { GapAutoFillSchedulerSO } from '../../../../data/gap_auto_fill_scheduler/types/gap_auto_fill_scheduler'; +import { ReadOperations, AlertingAuthorizationEntity } from '../../../../authorization'; +import { + gapAutoFillSchedulerAuditEvent, + GapAutoFillSchedulerAuditAction, +} from '../../../../rules_client/common/audit_events'; +import { GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; + +export async function getGapAutoFillScheduler( + context: RulesClientContext, + params: GetGapAutoFillSchedulerParams +): Promise { + try { + getGapAutoFillSchedulerSchema.validate(params); + } catch (error) { + throw Boom.badRequest( + `Error validating gap auto fill scheduler get parameters "${JSON.stringify(params)}" - ${ + error.message + }` + ); + } + + try { + const result = await context.unsecuredSavedObjectsClient.get( + GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, + params.id + ); + + if (result.error) { + const err = new Error(result.error.message); + context.auditLogger?.log( + gapAutoFillSchedulerAuditEvent({ + action: GapAutoFillSchedulerAuditAction.GET, + savedObject: { + type: GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, + id: params.id, + name: result.attributes.name, + }, + error: new Error(result.error.message), + }) + ); + throw err; + } + + // Authorization check - we need to check if user has permission to get + // For gap fill auto scheduler, we check against the rule types it manages + const ruleTypes = result.attributes.ruleTypes || []; + + try { + for (const ruleType of ruleTypes) { + await context.authorization.ensureAuthorized({ + ruleTypeId: ruleType.type, + consumer: ruleType.consumer, + operation: ReadOperations.GetGapAutoFillScheduler, + entity: AlertingAuthorizationEntity.Rule, + }); + } + } catch (error) { + context.auditLogger?.log( + gapAutoFillSchedulerAuditEvent({ + action: GapAutoFillSchedulerAuditAction.GET, + savedObject: { + type: GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, + id: params.id, + name: result.attributes.name, + }, + error, + }) + ); + throw error; + } + + context.auditLogger?.log( + gapAutoFillSchedulerAuditEvent({ + action: GapAutoFillSchedulerAuditAction.GET, + savedObject: { + type: GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, + id: params.id, + name: result.attributes.name, + }, + }) + ); + + return transformSavedObjectToGapAutoFillSchedulerResult({ + savedObject: result, + }); + } catch (err) { + const errorMessage = `Failed to get gap fill auto scheduler by id: ${params.id}`; + context.logger.error(`${errorMessage} - ${err}`); + throw Boom.boomify(err, { message: errorMessage }); + } +} diff --git a/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get/index.ts b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get/index.ts new file mode 100644 index 0000000000000..bd3b1a175a027 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { getGapAutoFillScheduler } from './get_gap_auto_fill_scheduler'; +export * from './schemas'; +export type * from './types'; diff --git a/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get/schemas/get_gap_auto_fill_scheduler_schema.ts b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get/schemas/get_gap_auto_fill_scheduler_schema.ts new file mode 100644 index 0000000000000..d48307ee20807 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get/schemas/get_gap_auto_fill_scheduler_schema.ts @@ -0,0 +1,12 @@ +/* + * 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 { schema } from '@kbn/config-schema'; + +export const getGapAutoFillSchedulerSchema = schema.object({ + id: schema.string(), +}); diff --git a/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get/schemas/index.ts b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get/schemas/index.ts new file mode 100644 index 0000000000000..8a780ef980bf1 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get/schemas/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { getGapAutoFillSchedulerSchema } from './get_gap_auto_fill_scheduler_schema'; diff --git a/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get/types/get_gap_auto_fill_scheduler_types.ts b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get/types/get_gap_auto_fill_scheduler_types.ts new file mode 100644 index 0000000000000..bfa771404cad1 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get/types/get_gap_auto_fill_scheduler_types.ts @@ -0,0 +1,11 @@ +/* + * 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 { TypeOf } from '@kbn/config-schema'; +import type { getGapAutoFillSchedulerSchema } from '../schemas'; + +export type GetGapAutoFillSchedulerParams = TypeOf; diff --git a/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get/types/index.ts b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get/types/index.ts new file mode 100644 index 0000000000000..07c1928b41eb9 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get/types/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type { GetGapAutoFillSchedulerParams } from './get_gap_auto_fill_scheduler_types'; diff --git a/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get_logs/get_gap_auto_fill_scheduler_logs.test.ts b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get_logs/get_gap_auto_fill_scheduler_logs.test.ts new file mode 100644 index 0000000000000..61cac3e4cf9a0 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get_logs/get_gap_auto_fill_scheduler_logs.test.ts @@ -0,0 +1,291 @@ +/* + * 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 { ActionsAuthorization } from '@kbn/actions-plugin/server'; +import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; +import type { IValidatedEventInternalDocInfo } from '@kbn/event-log-plugin/server'; +import { + savedObjectsClientMock, + savedObjectsRepositoryMock, +} from '@kbn/core-saved-objects-api-server-mocks'; +import { uiSettingsServiceMock } from '@kbn/core-ui-settings-server-mocks'; +import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; +import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; +import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; +import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks'; +import type { AlertingAuthorization } from '../../../../authorization'; +import { alertingAuthorizationMock } from '../../../../authorization/alerting_authorization.mock'; +import { ruleTypeRegistryMock } from '../../../../rule_type_registry.mock'; +import type { SavedObject } from '@kbn/core/server'; +import type { GapAutoFillSchedulerSO } from '../../../../data/gap_auto_fill_scheduler/types/gap_auto_fill_scheduler'; +import { GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; +import { RulesClient } from '../../../../rules_client'; +import { ReadOperations, AlertingAuthorizationEntity } from '../../../../authorization'; +import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry'; +import { GapAutoFillSchedulerAuditAction } from '../../../../rules_client/common/audit_events'; +import type { GetGapAutoFillSchedulerLogsParams } from './types'; + +const kibanaVersion = 'v8.0.0'; +const taskManager = taskManagerMock.createStart(); +const ruleTypeRegistry = ruleTypeRegistryMock.create(); +const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); +const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); +const authorization = alertingAuthorizationMock.create(); +const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditLoggerMock.create(); +const internalSavedObjectsRepository = savedObjectsRepositoryMock.create(); + +const findEventsBySavedObjectIdsMock = jest.fn(); +const eventLogClientMock = { + findEventsBySavedObjectIds: findEventsBySavedObjectIdsMock, +}; + +const getEventLogClient = jest.fn(); + +const mockLogs: Partial[] = [ + { + '@timestamp': '2023-01-01T00:00:00.000Z', + message: 'Gap fill auto scheduler logs', + kibana: { + gap_auto_fill: { + execution: { + status: 'success', + results: [ + { + rule_id: '1', + processed_gaps: 10, + status: 'success', + }, + ], + }, + }, + }, + }, +]; + +describe('getGapAutoFillSchedulerLogs()', () => { + let rulesClient: RulesClient; + const now = new Date().toISOString(); + + const schedulerSO: SavedObject = { + id: 'gap-1', + type: GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, + attributes: { + id: 'gap-1', + name: 'auto-fill', + enabled: true, + schedule: { interval: '1h' }, + gapFillRange: 'now-1d', + maxBackfills: 100, + numRetries: 3, + scope: ['internal'], + ruleTypes: [ + { type: 'test-rule-type1', consumer: 'test-consumer' }, + { type: 'test-rule-type2', consumer: 'test-consumer' }, + ], + ruleTypeConsumerPairs: ['test-rule-type1:test-consumer', 'test-rule-type2:test-consumer'], + createdAt: now, + updatedAt: now, + createdBy: 'elastic', + updatedBy: 'elastic', + }, + references: [], + }; + + beforeEach(() => { + jest.resetAllMocks(); + + rulesClient = new RulesClient({ + taskManager, + ruleTypeRegistry, + unsecuredSavedObjectsClient, + authorization: authorization as unknown as AlertingAuthorization, + actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, + spaceId: 'default', + namespace: 'default', + getUserName: jest.fn(), + createAPIKey: jest.fn(), + logger: loggingSystemMock.create().get(), + internalSavedObjectsRepository, + encryptedSavedObjectsClient: encryptedSavedObjects, + getActionsClient: jest.fn(), + getEventLogClient, + kibanaVersion, + auditLogger, + maxScheduledPerMinute: 10000, + minimumScheduleInterval: { value: '1m', enforce: false }, + isAuthenticationTypeAPIKey: jest.fn(), + getAuthenticationAPIKey: jest.fn(), + getAlertIndicesAlias: jest.fn(), + alertsService: null, + backfillClient: null as unknown as never, + isSystemAction: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), + uiSettings: uiSettingsServiceMock.createStartContract(), + }); + + unsecuredSavedObjectsClient.get.mockResolvedValue(schedulerSO); + getEventLogClient.mockResolvedValue(eventLogClientMock); + findEventsBySavedObjectIdsMock.mockResolvedValue({ + data: mockLogs, + total: 1, + page: 1, + per_page: 10, + }); + }); + + test('should successfully get gap fill auto scheduler logs with default sort and no status filter', async () => { + const result = await rulesClient.getGapAutoFillSchedulerLogs({ + id: 'gap-1', + page: 1, + perPage: 10, + start: '2023-01-01T00:00:00.000Z', + end: '2023-01-02T00:00:00.000Z', + sortField: '@timestamp', + sortDirection: 'desc', + }); + + // Saved object is retrieved + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledWith( + GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, + 'gap-1' + ); + + // Authorization is checked for each rule type + expect(authorization.ensureAuthorized).toHaveBeenCalledTimes( + schedulerSO.attributes.ruleTypes!.length + ); + for (const ruleType of schedulerSO.attributes.ruleTypes ?? []) { + expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ + ruleTypeId: ruleType.type, + consumer: ruleType.consumer, + operation: ReadOperations.GetGapAutoFillSchedulerLogs, + entity: AlertingAuthorizationEntity.Rule, + }); + } + + // Event log client is called with correct params + expect(getEventLogClient).toHaveBeenCalledTimes(1); + expect(findEventsBySavedObjectIdsMock).toHaveBeenCalledWith('task', ['gap-1'], { + page: 1, + per_page: 10, + start: '2023-01-01T00:00:00.000Z', + end: '2023-01-02T00:00:00.000Z', + sort: [{ sort_field: '@timestamp', sort_order: 'desc' }], + filter: 'event.action:gap-auto-fill-schedule', + }); + + // Audit log is written for successful GET_LOGS + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: GapAutoFillSchedulerAuditAction.GET_LOGS, + }), + }) + ); + + // The data is formatted and returned correctly + expect(result).toEqual({ + data: [ + { + timestamp: '2023-01-01T00:00:00.000Z', + status: 'success', + message: 'Gap fill auto scheduler logs', + results: [ + { + ruleId: '1', + processedGaps: 10, + status: 'success', + }, + ], + }, + ], + total: 1, + page: 1, + perPage: 10, + }); + }); + + test('should include status filters when statuses are provided', async () => { + await rulesClient.getGapAutoFillSchedulerLogs({ + id: 'gap-1', + page: 2, + perPage: 5, + start: '2023-01-01T00:00:00.000Z', + end: '2023-01-02T00:00:00.000Z', + statuses: ['success', 'error'], + sortField: '@timestamp', + sortDirection: 'desc', + }); + + expect(findEventsBySavedObjectIdsMock).toHaveBeenCalledWith('task', ['gap-1'], { + page: 2, + per_page: 5, + start: '2023-01-01T00:00:00.000Z', + end: '2023-01-02T00:00:00.000Z', + sort: [{ sort_field: '@timestamp', sort_order: 'desc' }], + filter: + 'event.action:gap-auto-fill-schedule AND (kibana.gap_auto_fill.execution.status : success OR kibana.gap_auto_fill.execution.status : error)', + }); + }); + + describe('error handling', () => { + test('should throw error when getting saved object fails', async () => { + unsecuredSavedObjectsClient.get.mockImplementationOnce(() => { + throw new Error('error getting SO!'); + }); + + await expect( + rulesClient.getGapAutoFillSchedulerLogs({ + id: 'gap-1', + page: 1, + perPage: 10, + start: '2023-01-01T00:00:00.000Z', + end: '2023-01-02T00:00:00.000Z', + sortField: '@timestamp', + sortDirection: 'desc', + }) + ).rejects.toThrowError(/error getting SO!/); + }); + + test('should audit and throw when authorization fails', async () => { + (authorization.ensureAuthorized as jest.Mock).mockImplementationOnce(() => { + throw new Error('Unauthorized'); + }); + + await expect( + rulesClient.getGapAutoFillSchedulerLogs({ + id: 'gap-1', + page: 1, + perPage: 10, + start: '2023-01-01T00:00:00.000Z', + end: '2023-01-02T00:00:00.000Z', + sortField: '@timestamp', + sortDirection: 'desc', + }) + ).rejects.toThrowError(/Failed to get gap fill auto scheduler logs by id: gap-1/); + + // Audit contains the error + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ + message: 'Unauthorized', + }), + }) + ); + }); + + test('validate params and throw error when invalid', async () => { + await expect( + rulesClient.getGapAutoFillSchedulerLogs({ + id: 'gap-1', + page: 1, + perPage: 10, + } as unknown as GetGapAutoFillSchedulerLogsParams) + ).rejects.toThrowError(/Error validating gap auto fill scheduler logs parameters/); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get_logs/get_gap_auto_fill_scheduler_logs.ts b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get_logs/get_gap_auto_fill_scheduler_logs.ts new file mode 100644 index 0000000000000..68e214f3b5b0a --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get_logs/get_gap_auto_fill_scheduler_logs.ts @@ -0,0 +1,148 @@ +/* + * 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 Boom from '@hapi/boom'; +import type { RulesClientContext } from '../../../../rules_client/types'; +import type { + GetGapAutoFillSchedulerLogsParams, + GapAutoFillSchedulerLogsResult, + GapAutoFillSchedulerLogEntry, +} from './types'; +import { getGapAutoFillSchedulerLogsParamsSchema } from './schemas'; +import type { GapAutoFillSchedulerSO } from '../../../../data/gap_auto_fill_scheduler/types/gap_auto_fill_scheduler'; +import { ReadOperations, AlertingAuthorizationEntity } from '../../../../authorization'; +import { + gapAutoFillSchedulerAuditEvent, + GapAutoFillSchedulerAuditAction, +} from '../../../../rules_client/common/audit_events'; +import { GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; +import { formatGapAutoFillSchedulerLogEntry } from './utils'; + +export async function getGapAutoFillSchedulerLogs( + context: RulesClientContext, + params: GetGapAutoFillSchedulerLogsParams +): Promise { + try { + // Validate input parameters + getGapAutoFillSchedulerLogsParamsSchema.validate(params); + } catch (error) { + throw Boom.badRequest( + `Error validating gap auto fill scheduler logs parameters "${JSON.stringify(params)}" - ${ + error.message + }` + ); + } + + try { + // Get the scheduler saved object to access ruleTypes for authorization + const schedulerSO = await context.unsecuredSavedObjectsClient.get( + GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, + params.id + ); + + // Check for errors in the savedObjectClient result + if (schedulerSO.error) { + const err = new Error(schedulerSO.error.message); + context.auditLogger?.log( + gapAutoFillSchedulerAuditEvent({ + action: GapAutoFillSchedulerAuditAction.GET, + savedObject: { + type: GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, + id: params.id, + name: schedulerSO.attributes.name, + }, + error: new Error(schedulerSO.error.message), + }) + ); + throw err; + } + + // Authorization check - we need to check if user has permission to get logs + // For gap fill auto scheduler, we check against the rule types it manages + const ruleTypes = schedulerSO.attributes.ruleTypes || []; + + try { + for (const ruleType of ruleTypes) { + await context.authorization.ensureAuthorized({ + ruleTypeId: ruleType.type, + consumer: ruleType.consumer, + operation: ReadOperations.GetGapAutoFillSchedulerLogs, + entity: AlertingAuthorizationEntity.Rule, + }); + } + } catch (error) { + context.auditLogger?.log( + gapAutoFillSchedulerAuditEvent({ + action: GapAutoFillSchedulerAuditAction.GET_LOGS, + savedObject: { + type: GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, + id: params.id, + name: schedulerSO.attributes.name, + }, + error, + }) + ); + throw error; + } + + // Get the task ID from the scheduler saved object + const taskId = schedulerSO.id; + + // Get event log client and query for gap-auto-fill-schedule events + const eventLogClient = await context.getEventLogClient(); + + const filters = ['event.action:gap-auto-fill-schedule']; + if (params.statuses) { + const statusFilters = `(${params.statuses + .map((status) => `kibana.gap_auto_fill.execution.status : ${status}`) + .join(' OR ')})`; + filters.push(statusFilters); + } + + const result = await eventLogClient.findEventsBySavedObjectIds('task', [taskId], { + page: params.page, + per_page: params.perPage, + start: params.start, + end: params.end, + sort: [ + { + sort_field: params.sortField, + sort_order: params.sortDirection, + }, + ], + filter: filters.join(' AND '), + }); + + // Log successful get logs + context.auditLogger?.log( + gapAutoFillSchedulerAuditEvent({ + action: GapAutoFillSchedulerAuditAction.GET_LOGS, + savedObject: { + type: GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, + id: params.id, + name: schedulerSO.attributes.name, + }, + }) + ); + + // Transform raw event log data into cleaner format + const transformedData: GapAutoFillSchedulerLogEntry[] = result.data.map( + formatGapAutoFillSchedulerLogEntry + ); + + return { + data: transformedData, + total: result.total, + page: result.page, + perPage: result.per_page, + }; + } catch (err) { + const errorMessage = `Failed to get gap fill auto scheduler logs by id: ${params.id}`; + context.logger.error(`${errorMessage} - ${err}`); + throw Boom.boomify(err, { message: errorMessage }); + } +} diff --git a/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get_logs/index.ts b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get_logs/index.ts new file mode 100644 index 0000000000000..56285816038cf --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get_logs/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { getGapAutoFillSchedulerLogs } from './get_gap_auto_fill_scheduler_logs'; +export type { GetGapAutoFillSchedulerLogsParams, GapAutoFillSchedulerLogsResult } from './types'; diff --git a/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get_logs/schemas/get_gap_auto_fill_scheduler_logs_schema.ts b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get_logs/schemas/get_gap_auto_fill_scheduler_logs_schema.ts new file mode 100644 index 0000000000000..d0fa8fc16761b --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get_logs/schemas/get_gap_auto_fill_scheduler_logs_schema.ts @@ -0,0 +1,54 @@ +/* + * 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 { schema } from '@kbn/config-schema'; + +export const getGapAutoFillSchedulerLogsParamsSchema = schema.object({ + id: schema.string(), + start: schema.string(), + end: schema.string(), + page: schema.number({ defaultValue: 1, min: 1 }), + perPage: schema.number({ defaultValue: 50, min: 1, max: 100 }), + sortField: schema.oneOf([schema.literal('@timestamp')], { defaultValue: '@timestamp' }), + sortDirection: schema.oneOf([schema.literal('asc'), schema.literal('desc')], { + defaultValue: 'desc', + }), + statuses: schema.maybe( + schema.arrayOf( + schema.oneOf([ + schema.literal('success'), + schema.literal('error'), + schema.literal('skipped'), + schema.literal('no_gaps'), + ]) + ) + ), +}); + +export const gapAutoFillSchedulerLogEntrySchema = schema.object({ + id: schema.string(), + timestamp: schema.maybe(schema.string()), + status: schema.maybe(schema.string()), + message: schema.maybe(schema.string()), + results: schema.maybe( + schema.arrayOf( + schema.object({ + ruleId: schema.maybe(schema.string()), + processedGaps: schema.maybe(schema.number()), + status: schema.maybe(schema.string()), + error: schema.maybe(schema.string()), + }) + ) + ), +}); + +export const gapAutoFillSchedulerLogsResultSchema = schema.object({ + data: schema.arrayOf(gapAutoFillSchedulerLogEntrySchema), + total: schema.number(), + page: schema.number(), + perPage: schema.number(), +}); diff --git a/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get_logs/schemas/index.ts b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get_logs/schemas/index.ts new file mode 100644 index 0000000000000..a217966ce0fe0 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get_logs/schemas/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { + getGapAutoFillSchedulerLogsParamsSchema, + gapAutoFillSchedulerLogEntrySchema, + gapAutoFillSchedulerLogsResultSchema, +} from './get_gap_auto_fill_scheduler_logs_schema'; diff --git a/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get_logs/types/get_gap_auto_fill_scheduler_logs_types.ts b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get_logs/types/get_gap_auto_fill_scheduler_logs_types.ts new file mode 100644 index 0000000000000..8a5e8850c06d3 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get_logs/types/get_gap_auto_fill_scheduler_logs_types.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { TypeOf } from '@kbn/config-schema'; + +import type { + getGapAutoFillSchedulerLogsParamsSchema, + gapAutoFillSchedulerLogEntrySchema, + gapAutoFillSchedulerLogsResultSchema, +} from '../schemas'; + +export type GetGapAutoFillSchedulerLogsParams = TypeOf< + typeof getGapAutoFillSchedulerLogsParamsSchema +>; + +export type GapAutoFillSchedulerLogEntry = TypeOf; + +export type GapAutoFillSchedulerLogsResult = TypeOf; diff --git a/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get_logs/types/index.ts b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get_logs/types/index.ts new file mode 100644 index 0000000000000..3d3a58d0f0591 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get_logs/types/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type { + GetGapAutoFillSchedulerLogsParams, + GapAutoFillSchedulerLogsResult, + GapAutoFillSchedulerLogEntry, +} from './get_gap_auto_fill_scheduler_logs_types'; diff --git a/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get_logs/utils.ts b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get_logs/utils.ts new file mode 100644 index 0000000000000..091fb0fcbb9e2 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/get_logs/utils.ts @@ -0,0 +1,29 @@ +/* + * 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 { IValidatedEventInternalDocInfo } from '@kbn/event-log-plugin/server'; +import type { GapAutoFillSchedulerLogEntry } from './types'; + +export const formatGapAutoFillSchedulerLogEntry = ( + entry: IValidatedEventInternalDocInfo +): GapAutoFillSchedulerLogEntry => { + const execution = entry.kibana?.gap_auto_fill?.execution; + const executionResults = execution?.results ?? []; + + return { + id: entry._id, + timestamp: entry['@timestamp'], + status: execution?.status, + message: entry.message, + results: executionResults?.map((resultItem) => ({ + ruleId: resultItem.rule_id, + processedGaps: resultItem.processed_gaps ? Number(resultItem.processed_gaps) : undefined, + status: resultItem.status, + error: resultItem.error, + })), + }; +}; diff --git a/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/update/index.ts b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/update/index.ts new file mode 100644 index 0000000000000..b0e8bf8d0e4ac --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/update/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { updateGapAutoFillScheduler } from './update_gap_auto_fill_scheduler'; +export * from './schemas'; +export type * from './types'; diff --git a/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/update/schemas/index.ts b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/update/schemas/index.ts new file mode 100644 index 0000000000000..29301f87dbe97 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/update/schemas/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { updateGapAutoFillSchedulerSchema } from './update_gap_auto_fill_scheduler_schema'; diff --git a/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/update/schemas/update_gap_auto_fill_scheduler_schema.ts b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/update/schemas/update_gap_auto_fill_scheduler_schema.ts new file mode 100644 index 0000000000000..5f8f618418342 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/update/schemas/update_gap_auto_fill_scheduler_schema.ts @@ -0,0 +1,34 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { gapAutoFillSchedulerLimits } from '../../../../../../common/constants'; + +const { maxBackfills, numRetries } = gapAutoFillSchedulerLimits; + +export const updateGapAutoFillSchedulerSchema = schema.object({ + id: schema.string(), + name: schema.string(), + enabled: schema.boolean(), + gapFillRange: schema.string(), + maxBackfills: schema.number(maxBackfills), + numRetries: schema.number(numRetries), + schedule: schema.object({ + interval: schema.string(), + }), + scope: schema.arrayOf(schema.string()), + ruleTypes: schema.arrayOf( + schema.object({ + type: schema.string(), + consumer: schema.string(), + }), + { + minSize: 1, + } + ), + request: schema.any(), +}); diff --git a/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/update/types/index.ts b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/update/types/index.ts new file mode 100644 index 0000000000000..f646b5acdc65a --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/update/types/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type { UpdateGapAutoFillSchedulerParams } from './update_gap_auto_fill_scheduler_types'; diff --git a/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/update/types/update_gap_auto_fill_scheduler_types.ts b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/update/types/update_gap_auto_fill_scheduler_types.ts new file mode 100644 index 0000000000000..a9c6eff8b6cea --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/update/types/update_gap_auto_fill_scheduler_types.ts @@ -0,0 +1,17 @@ +/* + * 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 { TypeOf } from '@kbn/config-schema'; +import type { KibanaRequest } from '@kbn/core/server'; +import type { updateGapAutoFillSchedulerSchema } from '../schemas'; + +export type UpdateGapAutoFillSchedulerBase = TypeOf; + +export interface UpdateGapAutoFillSchedulerParams + extends Omit { + request: KibanaRequest; +} diff --git a/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/update/update_gap_auto_fill_scheduler.test.ts b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/update/update_gap_auto_fill_scheduler.test.ts new file mode 100644 index 0000000000000..e27ec75172698 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/update/update_gap_auto_fill_scheduler.test.ts @@ -0,0 +1,250 @@ +/* + * 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 { ActionsAuthorization } from '@kbn/actions-plugin/server'; +import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks'; +import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; +import { + savedObjectsClientMock, + savedObjectsRepositoryMock, +} from '@kbn/core-saved-objects-api-server-mocks'; +import { uiSettingsServiceMock } from '@kbn/core-ui-settings-server-mocks'; +import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; +import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; +import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; +import { actionsClientMock } from '@kbn/actions-plugin/server/mocks'; +import type { SavedObject, Logger, KibanaRequest } from '@kbn/core/server'; +import { eventLoggerMock } from '@kbn/event-log-plugin/server/event_logger.mock'; +import { eventLogClientMock } from '@kbn/event-log-plugin/server/event_log_client.mock'; +import type { AlertingAuthorization } from '../../../../authorization'; +import { alertingAuthorizationMock } from '../../../../authorization/alerting_authorization.mock'; +import { ruleTypeRegistryMock } from '../../../../rule_type_registry.mock'; +import type { ConstructorOptions } from '../../../../rules_client'; +import { RulesClient } from '../../../../rules_client'; +import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry'; +import { GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; +import type { GapAutoFillSchedulerSO } from '../../../../data/gap_auto_fill_scheduler/types/gap_auto_fill_scheduler'; +import { backfillClientMock } from '../../../../backfill_client/backfill_client.mock'; +import type { UpdateGapAutoFillSchedulerParams } from './types'; + +const kibanaVersion = 'v8.0.0'; +const taskManager = taskManagerMock.createStart(); +const ruleTypeRegistry = ruleTypeRegistryMock.create(); +const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); +const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); +const authorization = alertingAuthorizationMock.create(); +const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditLoggerMock.create(); +const internalSavedObjectsRepository = savedObjectsRepositoryMock.create(); +const actionsClient = actionsClientMock.create(); +const eventLogger = eventLoggerMock.create(); +const eventLogClient = eventLogClientMock.create(); +const backfillClient = backfillClientMock.create(); + +const rulesClientParamsBase: jest.Mocked = { + taskManager, + ruleTypeRegistry, + unsecuredSavedObjectsClient, + authorization: authorization as unknown as AlertingAuthorization, + actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, + spaceId: 'default', + namespace: 'default', + getUserName: jest.fn().mockResolvedValue('elastic'), + createAPIKey: jest.fn(), + logger: loggingSystemMock.create().get() as Logger, + internalSavedObjectsRepository, + encryptedSavedObjectsClient: encryptedSavedObjects, + getActionsClient: jest.fn(), + getEventLogClient: jest.fn(), + kibanaVersion, + auditLogger, + maxScheduledPerMinute: 10000, + minimumScheduleInterval: { value: '1m', enforce: false }, + isAuthenticationTypeAPIKey: jest.fn(), + getAuthenticationAPIKey: jest.fn(), + getAlertIndicesAlias: jest.fn(), + alertsService: null, + backfillClient, + isSystemAction: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), + uiSettings: uiSettingsServiceMock.createStartContract(), + eventLogger, +}; + +const defaultRuleType = { type: 'test-rule-type', consumer: 'test-consumer' }; + +const getParams = (overrides: Partial = {}) => + ({ + id: 'scheduler-1', + name: 'updated name', + enabled: true, + schedule: { interval: '2h' }, + gapFillRange: 'now-30d', + maxBackfills: 200, + numRetries: 5, + scope: ['scope-a'], + ruleTypes: [defaultRuleType], + request: {} as KibanaRequest, + ...overrides, + } satisfies UpdateGapAutoFillSchedulerParams); + +function setupSchedulerSo(attrs?: Partial) { + const so: SavedObject = { + id: 'scheduler-1', + type: GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, + attributes: { + id: 'scheduler-1', + name: 'gap-scheduler', + enabled: true, + schedule: { interval: '1h' }, + gapFillRange: 'now-90d', + maxBackfills: 100, + numRetries: 3, + scope: ['scope-a'], + ruleTypes: [{ type: 'test-rule-type', consumer: 'test-consumer' }], + ruleTypeConsumerPairs: ['test-rule-type:test-consumer'], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + createdBy: 'elastic', + updatedBy: 'elastic', + ...attrs, + }, + references: [], + }; + unsecuredSavedObjectsClient.get.mockResolvedValue(so); + return so; +} + +describe('updateGapAutoFillScheduler()', () => { + let rulesClient: RulesClient; + + beforeEach(() => { + jest.resetAllMocks(); + + unsecuredSavedObjectsClient.find.mockResolvedValue({ + saved_objects: [], + total: 0, + per_page: 1, + page: 1, + }); + + rulesClient = new RulesClient(rulesClientParamsBase); + rulesClientParamsBase.getActionsClient.mockResolvedValue(actionsClient); + rulesClientParamsBase.getEventLogClient.mockResolvedValue(eventLogClient); + }); + + test('updates scheduler attributes and ensures task when enabled', async () => { + const existingSo = setupSchedulerSo(); + const updatedSo: SavedObject = { + ...existingSo, + attributes: { + ...existingSo.attributes, + name: 'updated name', + schedule: { interval: '2h' }, + gapFillRange: 'now-30d', + maxBackfills: 200, + numRetries: 5, + scope: ['scope-a'], + updatedAt: new Date().toISOString(), + updatedBy: 'elastic', + }, + }; + unsecuredSavedObjectsClient.update.mockResolvedValue(updatedSo); + // Mock get to return updatedSo on the second call (after update) + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(updatedSo); + + const params = getParams(); + await rulesClient.updateGapAutoFillScheduler(params); + + expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledWith( + GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, + params.id, + expect.objectContaining({ + name: 'updated name', + schedule: { interval: '2h' }, + scope: ['scope-a'], + }) + ); + + expect(taskManager.removeIfExists).toHaveBeenCalledWith(updatedSo.id); + expect(taskManager.ensureScheduled).toHaveBeenCalledWith( + expect.objectContaining({ + id: updatedSo.id, + schedule: { interval: '2h' }, + scope: ['scope-a'], + }), + { request: params.request } + ); + expect(backfillClient.deleteBackfillsByInitiatorId).not.toHaveBeenCalled(); + }); + + test('disables scheduler, removes task, and deletes backfills when enabled is set to false', async () => { + const existingSo = setupSchedulerSo(); + unsecuredSavedObjectsClient.update.mockResolvedValue(existingSo); + + const params = getParams({ enabled: false }); + await rulesClient.updateGapAutoFillScheduler(params); + + expect(taskManager.ensureScheduled).not.toHaveBeenCalled(); + expect(taskManager.removeIfExists).toHaveBeenCalledWith(existingSo.id); + expect(backfillClient.deleteBackfillsByInitiatorId).toHaveBeenCalledWith( + expect.objectContaining({ + initiatorId: existingSo.id, + unsecuredSavedObjectsClient, + shouldUpdateGaps: true, + internalSavedObjectsRepository, + eventLogClient, + eventLogger, + actionsClient, + }) + ); + }); + + test('validates params and throws on invalid payload', async () => { + expect.assertions(2); + + try { + await rulesClient.updateGapAutoFillScheduler({ + id: 'scheduler-1', + request: {} as KibanaRequest, + } as UpdateGapAutoFillSchedulerParams); + } catch (err) { + const message = String((err as Error).message ?? err); + expect(message).toMatch(/Error validating gap auto fill scheduler update parameters/); + expect(message).not.toContain('"request"'); + } + }); + + test('logs and rethrows when authorization fails', async () => { + setupSchedulerSo(); + (authorization.ensureAuthorized as jest.Mock).mockImplementationOnce(() => { + throw new Error('no access'); + }); + + await expect( + rulesClient.updateGapAutoFillScheduler(getParams({ request: {} as KibanaRequest })) + ).rejects.toThrow('no access'); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ action: 'gap_auto_fill_scheduler_update' }), + error: expect.any(Object), + }) + ); + }); + + test('throws when rule type is not registered', async () => { + setupSchedulerSo(); + ruleTypeRegistry.get.mockImplementationOnce(() => { + throw new Error('unknown rule type'); + }); + + await expect(rulesClient.updateGapAutoFillScheduler(getParams())).rejects.toThrow( + 'unknown rule type' + ); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/update/update_gap_auto_fill_scheduler.ts b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/update/update_gap_auto_fill_scheduler.ts new file mode 100644 index 0000000000000..cc38394d9da65 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/gap_auto_fill_scheduler/methods/update/update_gap_auto_fill_scheduler.ts @@ -0,0 +1,217 @@ +/* + * 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 Boom from '@hapi/boom'; +import type { SavedObject } from '@kbn/core/server'; +import type { RulesClientContext } from '../../../../rules_client/types'; +import type { GapAutoFillSchedulerSO } from '../../../../data/gap_auto_fill_scheduler/types/gap_auto_fill_scheduler'; +import { GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; +import { + gapAutoFillSchedulerAuditEvent, + GapAutoFillSchedulerAuditAction, +} from '../../../../rules_client/common/audit_events'; +import { WriteOperations, AlertingAuthorizationEntity } from '../../../../authorization'; +import { GAP_AUTO_FILL_SCHEDULER_TASK_TYPE } from '../../../gaps/types/scheduler'; +import { transformSavedObjectToGapAutoFillSchedulerResult } from '../../transforms'; +import type { GapAutoFillSchedulerResponse } from '../../result/types'; +import { updateGapAutoFillSchedulerSchema } from './schemas'; +import type { UpdateGapAutoFillSchedulerParams } from './types'; + +const toRuleTypeKey = (ruleType: { type: string; consumer: string }) => + `${ruleType.type}:${ruleType.consumer}`; + +const buildRuleTypeUnion = ( + existing: Array<{ type: string; consumer: string }>, + incoming: Array<{ type: string; consumer: string }> = [] +) => { + const map = new Map(); + for (const ruleType of [...existing, ...incoming]) { + map.set(toRuleTypeKey(ruleType), ruleType); + } + return Array.from(map.values()); +}; + +export async function updateGapAutoFillScheduler( + context: RulesClientContext, + params: UpdateGapAutoFillSchedulerParams +): Promise { + try { + updateGapAutoFillSchedulerSchema.validate(params); + } catch (error) { + const { request: _req, ...otherParams } = params; + throw Boom.badRequest( + `Error validating gap auto fill scheduler update parameters - ${ + (error as Error).message + }\nPayload summary: ${JSON.stringify(otherParams)}` + ); + } + + const soClient = context.unsecuredSavedObjectsClient; + const taskManager = context.taskManager; + let schedulerSo: SavedObject; + + try { + schedulerSo = await soClient.get( + GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, + params.id + ); + } catch (err) { + const errorMessage = `Failed to load gap auto fill scheduler by id: ${params.id}`; + context.logger.error(`${errorMessage} - ${err}`); + throw Boom.boomify(err as Error, { message: errorMessage }); + } + + const schedulerName = schedulerSo.attributes?.name ?? params.id; + const existingRuleTypes = schedulerSo.attributes?.ruleTypes ?? []; + const username = await context.getUserName?.(); + const updatedRuleTypes = params.ruleTypes ?? existingRuleTypes; + const uniqueRuleTypeIds = new Set(updatedRuleTypes.map(({ type }) => type)); + + // Throw error if a rule type is not registered + for (const ruleTypeId of uniqueRuleTypeIds) { + context.ruleTypeRegistry.get(ruleTypeId); + } + + const updatedRuleTypePairs = Array.from(new Set(updatedRuleTypes.map(toRuleTypeKey))); + if (updatedRuleTypePairs.length > 0) { + const filter = `(${updatedRuleTypePairs + .map( + (pair) => + `${GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE}.attributes.ruleTypeConsumerPairs: "${pair}"` + ) + .join(' or ')})`; + + const { saved_objects: candidates } = await soClient.find({ + type: GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, + perPage: updatedRuleTypePairs.length + 1, + filter, + }); + + const hasConflictingPair = candidates.some((candidate) => candidate.id !== params.id); + if (hasConflictingPair) { + throw Boom.conflict( + `A gap auto fill scheduler already exists for at least one of the specified (rule type, consumer) pairs` + ); + } + } + + try { + const ruleTypesToCheck = buildRuleTypeUnion(existingRuleTypes, params.ruleTypes); + for (const ruleType of ruleTypesToCheck) { + await context.authorization.ensureAuthorized({ + ruleTypeId: ruleType.type, + consumer: ruleType.consumer, + operation: WriteOperations.UpdateGapAutoFillScheduler, + entity: AlertingAuthorizationEntity.Rule, + }); + } + } catch (error) { + context.auditLogger?.log( + gapAutoFillSchedulerAuditEvent({ + action: GapAutoFillSchedulerAuditAction.UPDATE, + savedObject: { + type: GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, + id: params.id, + name: schedulerName, + }, + error: error as Error, + }) + ); + throw error; + } + const now = new Date().toISOString(); + const updatedEnabled = params.enabled; + const updatedSchedule = params.schedule; + const updatedScope = params.scope !== undefined ? params.scope : schedulerSo.attributes.scope; + const updatedAttributes: Partial = { + name: params.name, + enabled: updatedEnabled, + schedule: updatedSchedule, + gapFillRange: params.gapFillRange, + maxBackfills: params.maxBackfills, + numRetries: params.numRetries, + scope: updatedScope, + ruleTypes: updatedRuleTypes, + ruleTypeConsumerPairs: Array.from(new Set(updatedRuleTypes.map(toRuleTypeKey))), + updatedAt: now, + updatedBy: username ?? undefined, + }; + + try { + await soClient.update( + GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, + params.id, + updatedAttributes + ); + const updatedSo = await soClient.get( + GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, + params.id + ); + const nextSchedulerName = updatedSo.attributes.name ?? schedulerName; + + await taskManager.removeIfExists(updatedSo.id); + + if (updatedEnabled) { + await taskManager.ensureScheduled( + { + id: updatedSo.id, + taskType: GAP_AUTO_FILL_SCHEDULER_TASK_TYPE, + schedule: updatedSchedule, + scope: updatedScope ?? [], + params: { + configId: updatedSo.id, + spaceId: context.spaceId, + }, + state: {}, + }, + { + request: params.request, + } + ); + } else { + await context.backfillClient.deleteBackfillsByInitiatorId({ + initiatorId: updatedSo.id, + unsecuredSavedObjectsClient: soClient, + shouldUpdateGaps: true, + internalSavedObjectsRepository: context.internalSavedObjectsRepository, + eventLogClient: await context.getEventLogClient(), + eventLogger: context.eventLogger, + actionsClient: await context.getActionsClient(), + }); + } + + context.auditLogger?.log( + gapAutoFillSchedulerAuditEvent({ + action: GapAutoFillSchedulerAuditAction.UPDATE, + savedObject: { + type: GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, + id: params.id, + name: nextSchedulerName, + }, + }) + ); + + return transformSavedObjectToGapAutoFillSchedulerResult({ + savedObject: updatedSo, + }); + } catch (err) { + const errorMessage = `Failed to update gap auto fill scheduler by id: ${params.id}`; + context.logger.error(`${errorMessage} - ${err}`); + context.auditLogger?.log( + gapAutoFillSchedulerAuditEvent({ + action: GapAutoFillSchedulerAuditAction.UPDATE, + savedObject: { + type: GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE, + id: params.id, + name: schedulerName, + }, + error: err as Error, + }) + ); + throw Boom.boomify(err as Error, { message: errorMessage }); + } +} diff --git a/x-pack/platform/plugins/shared/alerting/server/application/gaps/types/scheduler.ts b/x-pack/platform/plugins/shared/alerting/server/application/gaps/types/scheduler.ts index fac11597ae1cb..88dfff7ff17d3 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/gaps/types/scheduler.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/gaps/types/scheduler.ts @@ -7,7 +7,6 @@ import type { TypeOf } from '@kbn/config-schema'; import type { rawGapAutoFillSchedulerSchemaV1 } from '../../../saved_objects/schemas/raw_gap_auto_fill_scheduler'; - export type SchedulerSoAttributes = TypeOf; export const GAP_AUTO_FILL_SCHEDULER_TASK_TYPE = 'gap-auto-fill-scheduler-task' as const; @@ -17,13 +16,6 @@ export const DEFAULT_GAPS_PER_PAGE = 5000; export const DEFAULT_GAP_AUTO_FILL_SCHEDULER_TIMEOUT = '40s' as const; -export const GAP_AUTO_FILL_STATUS = { - SUCCESS: 'success', - ERROR: 'error', - SKIPPED: 'skipped', -} as const; -export type GapAutoFillStatus = (typeof GAP_AUTO_FILL_STATUS)[keyof typeof GAP_AUTO_FILL_STATUS]; - export type GapAutoFillSchedulerLogConfig = Pick< SchedulerSoAttributes, 'name' | 'numRetries' | 'gapFillRange' | 'schedule' | 'maxBackfills' | 'ruleTypes' diff --git a/x-pack/platform/plugins/shared/alerting/server/backfill_client/backfill_client.mock.ts b/x-pack/platform/plugins/shared/alerting/server/backfill_client/backfill_client.mock.ts index 2a4ea82eb54f8..cb881390f8fe4 100644 --- a/x-pack/platform/plugins/shared/alerting/server/backfill_client/backfill_client.mock.ts +++ b/x-pack/platform/plugins/shared/alerting/server/backfill_client/backfill_client.mock.ts @@ -9,6 +9,7 @@ const createBackfillClientMock = () => { return { bulkQueue: jest.fn(), deleteBackfillForRules: jest.fn(), + deleteBackfillsByInitiatorId: jest.fn(), findOverlappingBackfills: jest.fn(), }; }); diff --git a/x-pack/platform/plugins/shared/alerting/server/backfill_client/backfill_client.test.ts b/x-pack/platform/plugins/shared/alerting/server/backfill_client/backfill_client.test.ts index cfc9e410e2b6c..30909889b9fd9 100644 --- a/x-pack/platform/plugins/shared/alerting/server/backfill_client/backfill_client.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/backfill_client/backfill_client.test.ts @@ -2745,6 +2745,140 @@ describe('BackfillClient', () => { })) ); }); + + test('should call updateGaps for each backfill when shouldUpdateGaps is true (deleteBackfillForRules)', async () => { + const attrs1 = getMockAdHocRunAttributes({ + overwrites: { + start: '2023-11-16T08:00:00.000Z', + schedule: [ + { + runAt: '2023-11-16T20:00:00.000Z', + interval: '12h', + status: adHocRunStatus.PENDING, + }, + ], + }, + }); + const attrs2 = getMockAdHocRunAttributes({ + overwrites: { + start: '2023-11-17T08:00:00.000Z', + schedule: [ + { + runAt: '2023-11-17T20:00:00.000Z', + interval: '12h', + status: adHocRunStatus.PENDING, + }, + ], + }, + }); + + mockCreatePointInTimeFinderAsInternalUser({ + saved_objects: [ + { + id: 'abc', + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + attributes: attrs1, + references: [{ id: '1', name: 'rule', type: RULE_SAVED_OBJECT_TYPE }], + version: '1', + }, + { + id: 'def', + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + attributes: attrs2, + references: [{ id: '2', name: 'rule', type: RULE_SAVED_OBJECT_TYPE }], + version: '1', + }, + ], + }); + unsecuredSavedObjectsClient.bulkDelete.mockResolvedValueOnce({ + statuses: [ + { id: 'abc', type: AD_HOC_RUN_SAVED_OBJECT_TYPE, success: true }, + { id: 'def', type: AD_HOC_RUN_SAVED_OBJECT_TYPE, success: true }, + ], + }); + taskManagerStart.bulkRemove.mockResolvedValueOnce({ + statuses: [ + { id: 'abc', type: 'task', success: true }, + { id: 'def', type: 'task', success: true }, + ], + }); + + await backfillClient.deleteBackfillForRules({ + ruleIds: ['1', '2'], + unsecuredSavedObjectsClient, + shouldUpdateGaps: true, + internalSavedObjectsRepository, + eventLogClient, + eventLogger, + actionsClient, + }); + + expect(updateGaps).toHaveBeenCalledTimes(2); + expect(updateGaps).toHaveBeenNthCalledWith(1, { + ruleId: '1', + start: new Date(attrs1.start), + end: new Date(), + backfillSchedule: attrs1.schedule, + savedObjectsRepository: internalSavedObjectsRepository, + logger, + eventLogClient, + eventLogger, + shouldRefetchAllBackfills: true, + backfillClient, + actionsClient, + }); + expect(updateGaps).toHaveBeenNthCalledWith(2, { + ruleId: '2', + start: new Date(attrs2.start), + end: new Date(), + backfillSchedule: attrs2.schedule, + savedObjectsRepository: internalSavedObjectsRepository, + logger, + eventLogClient, + eventLogger, + shouldRefetchAllBackfills: true, + backfillClient, + actionsClient, + }); + }); + + test('should log warning if updateGaps throws during delete (deleteBackfillForRules)', async () => { + const attrs = getMockAdHocRunAttributes({ + overwrites: { start: '2023-11-16T08:00:00.000Z' }, + }); + mockCreatePointInTimeFinderAsInternalUser({ + saved_objects: [ + { + id: 'abc', + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + attributes: attrs, + references: [{ id: '1', name: 'rule', type: RULE_SAVED_OBJECT_TYPE }], + version: '1', + }, + ], + }); + unsecuredSavedObjectsClient.bulkDelete.mockResolvedValueOnce({ + statuses: [{ id: 'abc', type: AD_HOC_RUN_SAVED_OBJECT_TYPE, success: true }], + }); + taskManagerStart.bulkRemove.mockResolvedValueOnce({ + statuses: [{ id: 'abc', type: 'task', success: true }], + }); + (updateGaps as jest.Mock).mockRejectedValueOnce(new Error('Failed to update gaps')); + + await backfillClient.deleteBackfillForRules({ + ruleIds: ['1'], + unsecuredSavedObjectsClient, + shouldUpdateGaps: true, + internalSavedObjectsRepository, + eventLogClient, + eventLogger, + actionsClient, + }); + + expect(logger.warn).toHaveBeenCalledWith( + `Error updating gaps after deleting backfill abc: Failed to update gaps` + ); + }); }); describe('findOverlappingBackfills()', () => { @@ -2839,4 +2973,100 @@ describe('BackfillClient', () => { expect(result).toHaveLength(0); }); }); + + describe('deleteBackfillsByInitiatorId()', () => { + test('should successfully delete backfills by initiator id and use correct filter', async () => { + mockCreatePointInTimeFinderAsInternalUser({ + saved_objects: [ + { + id: 'abc', + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + attributes: getMockAdHocRunAttributes({ overwrites: { initiator: 'init-1' } }), + references: [{ id: '1', name: 'rule', type: RULE_SAVED_OBJECT_TYPE }], + version: '1', + }, + ], + }); + unsecuredSavedObjectsClient.bulkDelete.mockResolvedValueOnce({ + statuses: [{ id: 'abc', type: AD_HOC_RUN_SAVED_OBJECT_TYPE, success: true }], + }); + taskManagerStart.bulkRemove.mockResolvedValueOnce({ + statuses: [{ id: 'abc', type: 'task', success: true }], + }); + + await backfillClient.deleteBackfillsByInitiatorId({ + initiatorId: 'init-1', + unsecuredSavedObjectsClient, + }); + + expect(unsecuredSavedObjectsClient.createPointInTimeFinder).toHaveBeenCalledWith({ + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + perPage: 100, + filter: `${AD_HOC_RUN_SAVED_OBJECT_TYPE}.attributes.initiatorId: "init-1"`, + }); + expect(unsecuredSavedObjectsClient.bulkDelete).toHaveBeenCalledWith([ + { id: 'abc', type: AD_HOC_RUN_SAVED_OBJECT_TYPE }, + ]); + expect(taskManagerStart.bulkRemove).toHaveBeenCalledWith(['abc']); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + test('should call updateGaps when deleting by initiator with shouldUpdateGaps', async () => { + const attrs = getMockAdHocRunAttributes({ + overwrites: { + initiator: 'init-2', + start: '2023-11-16T08:00:00.000Z', + schedule: [ + { + runAt: '2023-11-16T20:00:00.000Z', + interval: '12h', + status: adHocRunStatus.PENDING, + }, + ], + }, + }); + mockCreatePointInTimeFinderAsInternalUser({ + saved_objects: [ + { + id: 'abc', + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + attributes: attrs, + references: [{ id: '1', name: 'rule', type: RULE_SAVED_OBJECT_TYPE }], + version: '1', + }, + ], + }); + unsecuredSavedObjectsClient.bulkDelete.mockResolvedValueOnce({ + statuses: [{ id: 'abc', type: AD_HOC_RUN_SAVED_OBJECT_TYPE, success: true }], + }); + taskManagerStart.bulkRemove.mockResolvedValueOnce({ + statuses: [{ id: 'abc', type: 'task', success: true }], + }); + + await backfillClient.deleteBackfillsByInitiatorId({ + initiatorId: 'init-2', + unsecuredSavedObjectsClient, + shouldUpdateGaps: true, + internalSavedObjectsRepository, + eventLogClient, + eventLogger, + actionsClient, + }); + + expect(updateGaps).toHaveBeenCalledTimes(1); + expect(updateGaps).toHaveBeenCalledWith({ + ruleId: '1', + start: new Date(attrs.start), + end: new Date(), + backfillSchedule: attrs.schedule, + savedObjectsRepository: internalSavedObjectsRepository, + logger, + eventLogClient, + eventLogger, + shouldRefetchAllBackfills: true, + backfillClient, + actionsClient, + }); + }); + }); }); diff --git a/x-pack/platform/plugins/shared/alerting/server/backfill_client/backfill_client.ts b/x-pack/platform/plugins/shared/alerting/server/backfill_client/backfill_client.ts index d4350449a4fd8..785389857b0a5 100644 --- a/x-pack/platform/plugins/shared/alerting/server/backfill_client/backfill_client.ts +++ b/x-pack/platform/plugins/shared/alerting/server/backfill_client/backfill_client.ts @@ -77,6 +77,21 @@ interface DeleteBackfillForRulesOpts { ruleIds: string[]; namespace?: string; unsecuredSavedObjectsClient: SavedObjectsClientContract; + shouldUpdateGaps?: boolean; + internalSavedObjectsRepository?: ISavedObjectsRepository; + eventLogClient?: IEventLogClient; + eventLogger?: IEventLogger; + actionsClient?: ActionsClient; +} + +interface DeleteBackfillsByInitiatorIdOpts { + initiatorId: string; + unsecuredSavedObjectsClient: SavedObjectsClientContract; + shouldUpdateGaps?: boolean; + internalSavedObjectsRepository?: ISavedObjectsRepository; + eventLogClient?: IEventLogClient; + eventLogger?: IEventLogger; + actionsClient?: ActionsClient; } export class BackfillClient { @@ -97,6 +112,107 @@ export class BackfillClient { }); } + private async deleteAdHocRunsAndTasks({ + unsecuredSavedObjectsClient, + adHocRuns, + shouldUpdateGaps, + internalSavedObjectsRepository, + eventLogClient, + eventLogger, + actionsClient, + }: { + unsecuredSavedObjectsClient: SavedObjectsClientContract; + adHocRuns: Array>; + shouldUpdateGaps?: boolean; + internalSavedObjectsRepository?: ISavedObjectsRepository; + eventLogClient?: IEventLogClient; + eventLogger?: IEventLogger; + actionsClient?: ActionsClient; + }) { + if (adHocRuns.length === 0) return; + + // Prepare backfill metadata for gap updates before deleting SOs + const backfillsForGapUpdate = + shouldUpdateGaps && actionsClient && internalSavedObjectsRepository && eventLogClient + ? adHocRuns.map((so) => + transformAdHocRunToBackfillResult({ + adHocRunSO: so, + isSystemAction: (id: string) => actionsClient.isSystemAction(id), + }) + ) + : []; + + const deleteResult = await unsecuredSavedObjectsClient.bulkDelete( + adHocRuns.map((adHocRun) => ({ + id: adHocRun.id, + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + })) + ); + + const deleteErrors = deleteResult.statuses.filter((status) => !!status.error); + if (deleteErrors.length > 0) { + this.logger.warn( + `Error deleting backfill jobs with IDs: ${deleteErrors + .map((status) => status.id) + .join(', ')} with errors: ${deleteErrors.map( + (status) => status.error?.message + )} - jobs and associated task were not deleted.` + ); + } + + if ( + shouldUpdateGaps && + backfillsForGapUpdate.length > 0 && + internalSavedObjectsRepository && + eventLogClient + ) { + for (const backfill of backfillsForGapUpdate) { + if ('rule' in backfill) { + try { + await updateGaps({ + ruleId: backfill.rule.id, + start: new Date(backfill.start), + end: backfill.end ? new Date(backfill.end) : new Date(), + backfillSchedule: backfill.schedule, + savedObjectsRepository: internalSavedObjectsRepository, + logger: this.logger, + eventLogClient, + eventLogger, + shouldRefetchAllBackfills: true, + backfillClient: this, + actionsClient: actionsClient!, + }); + } catch (e) { + this.logger.warn( + `Error updating gaps after deleting backfill ${backfill.id ?? 'unknown'}: ${ + (e as Error).message + }` + ); + } + } + } + } + + // delete the associated tasks + const taskIdsToDelete = deleteResult.statuses + .filter((status) => status.success) + .map((status) => status.id); + + // only delete tasks if the associated ad hoc runs were successfully deleted + const taskManager = await this.taskManagerStartPromise; + const deleteTaskResult = await taskManager.bulkRemove(taskIdsToDelete); + const deleteTaskErrors = deleteTaskResult.statuses.filter((status) => !!status.error); + if (deleteTaskErrors.length > 0) { + this.logger.warn( + `Error deleting tasks with IDs: ${deleteTaskErrors + .map((status) => status.id) + .join(', ')} with errors: ${deleteTaskErrors + .map((status) => status.error?.message) + .join(', ')}` + ); + } + } + public async bulkQueue({ actionsClient, auditLogger, @@ -391,6 +507,11 @@ export class BackfillClient { ruleIds, namespace, unsecuredSavedObjectsClient, + shouldUpdateGaps, + internalSavedObjectsRepository, + eventLogClient, + eventLogger, + actionsClient, }: DeleteBackfillForRulesOpts) { try { // query for all ad hoc runs that reference this ruleId @@ -406,46 +527,54 @@ export class BackfillClient { adHocRuns.push(...response.saved_objects); } await adHocRunFinder.close(); + await this.deleteAdHocRunsAndTasks({ + unsecuredSavedObjectsClient, + adHocRuns, + shouldUpdateGaps, + internalSavedObjectsRepository, + eventLogClient, + eventLogger, + actionsClient, + }); + } catch (error) { + this.logger.warn( + `Error deleting backfill jobs for rule IDs: ${ruleIds.join(',')} - ${error.message}` + ); + } + } - if (adHocRuns.length > 0) { - const deleteResult = await unsecuredSavedObjectsClient.bulkDelete( - adHocRuns.map((adHocRun) => ({ - id: adHocRun.id, - type: AD_HOC_RUN_SAVED_OBJECT_TYPE, - })) - ); - - const deleteErrors = deleteResult.statuses.filter((status) => !!status.error); - if (deleteErrors.length > 0) { - this.logger.warn( - `Error deleting backfill jobs with IDs: ${deleteErrors - .map((status) => status.id) - .join(', ')} with errors: ${deleteErrors.map( - (status) => status.error?.message - )} - jobs and associated task were not deleted.` - ); - } - - // only delete tasks if the associated ad hoc runs were successfully deleted - const taskIdsToDelete = deleteResult.statuses - .filter((status) => status.success) - .map((status) => status.id); - - // delete the associated tasks - const taskManager = await this.taskManagerStartPromise; - const deleteTaskResult = await taskManager.bulkRemove(taskIdsToDelete); - const deleteTaskErrors = deleteTaskResult.statuses.filter((status) => !!status.error); - if (deleteTaskErrors.length > 0) { - this.logger.warn( - `Error deleting tasks with IDs: ${deleteTaskErrors - .map((status) => status.id) - .join(', ')} with errors: ${deleteTaskErrors.map((status) => status.error?.message)}` - ); - } + public async deleteBackfillsByInitiatorId({ + initiatorId, + unsecuredSavedObjectsClient, + shouldUpdateGaps, + internalSavedObjectsRepository, + eventLogClient, + eventLogger, + actionsClient, + }: DeleteBackfillsByInitiatorIdOpts) { + try { + const adHocRunFinder = await unsecuredSavedObjectsClient.createPointInTimeFinder({ + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + perPage: 100, + filter: `${AD_HOC_RUN_SAVED_OBJECT_TYPE}.attributes.initiatorId: "${initiatorId}"`, + }); + const adHocRuns: Array> = []; + for await (const response of adHocRunFinder.find()) { + adHocRuns.push(...response.saved_objects); } + await adHocRunFinder.close(); + await this.deleteAdHocRunsAndTasks({ + unsecuredSavedObjectsClient, + adHocRuns, + shouldUpdateGaps, + internalSavedObjectsRepository, + eventLogClient, + eventLogger, + actionsClient, + }); } catch (error) { this.logger.warn( - `Error deleting backfill jobs for rule IDs: ${ruleIds.join(',')} - ${error.message}` + `Error deleting backfill jobs for initiatorId ${initiatorId} - ${(error as Error).message}` ); } } diff --git a/x-pack/platform/plugins/shared/alerting/server/lib/license_state.mock.ts b/x-pack/platform/plugins/shared/alerting/server/lib/license_state.mock.ts index d127a4e815ceb..f88362dff735c 100644 --- a/x-pack/platform/plugins/shared/alerting/server/lib/license_state.mock.ts +++ b/x-pack/platform/plugins/shared/alerting/server/lib/license_state.mock.ts @@ -12,6 +12,7 @@ export const createLicenseStateMock = () => { clean: jest.fn(), getLicenseInformation: jest.fn(), ensureLicenseForRuleType: jest.fn(), + ensureLicenseForGapAutoFillScheduler: jest.fn(), ensureLicenseForMaintenanceWindow: jest.fn(), getLicenseCheckForRuleType: jest.fn().mockResolvedValue({ isValid: true, diff --git a/x-pack/platform/plugins/shared/alerting/server/lib/license_state.ts b/x-pack/platform/plugins/shared/alerting/server/lib/license_state.ts index ac7964dc9b8b4..30b91421426ea 100644 --- a/x-pack/platform/plugins/shared/alerting/server/lib/license_state.ts +++ b/x-pack/platform/plugins/shared/alerting/server/lib/license_state.ts @@ -148,6 +148,32 @@ export class LicenseState { } } + public ensureLicenseForGapAutoFillScheduler() { + if (!this.license || !this.license?.isAvailable) { + throw Boom.forbidden( + i18n.translate( + 'xpack.alerting.serverSideErrors.gapAutoFillScheduler.unavailableLicenseErrorMessage', + { + defaultMessage: + 'Gap auto fill scheduler is disabled because license information is not available at this time.', + } + ) + ); + } + + if (!this.license.hasAtLeast('enterprise')) { + throw Boom.forbidden( + i18n.translate( + 'xpack.alerting.serverSideErrors.gapAutoFillScheduler.invalidLicenseErrorMessage', + { + defaultMessage: + 'Gap auto fill scheduler is disabled because it requires an enterprise license. Go to License Management to view upgrade options.', + } + ) + ); + } + } + public ensureLicenseForMaintenanceWindow() { if (!this.license || !this.license?.isAvailable) { throw Boom.forbidden( diff --git a/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/task/gap_auto_fill_scheduler_event_log.ts b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/task/gap_auto_fill_scheduler_event_log.ts index 4b27899d975ec..093071919677d 100644 --- a/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/task/gap_auto_fill_scheduler_event_log.ts +++ b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/task/gap_auto_fill_scheduler_event_log.ts @@ -7,10 +7,8 @@ import type { IEventLogger } from '@kbn/event-log-plugin/server'; import { EVENT_LOG_ACTIONS } from '../../../plugin'; -import type { - SchedulerSoAttributes, - GapAutoFillStatus, -} from '../../../application/gaps/types/scheduler'; +import type { SchedulerSoAttributes } from '../../../application/gaps/types/scheduler'; +import type { GapAutoFillStatus } from '../../../../common/constants'; export interface GapAutoFillExecutionResult { ruleId: string; diff --git a/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/task/gap_auto_fill_scheduler_task.test.ts b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/task/gap_auto_fill_scheduler_task.test.ts index 3514721e99cf5..1f0c709e14895 100644 --- a/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/task/gap_auto_fill_scheduler_task.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/task/gap_auto_fill_scheduler_task.test.ts @@ -390,7 +390,7 @@ describe('Gap Auto Fill Scheduler Task', () => { const result = await taskRunner.run(); expect(result).toEqual({ state: {} }); - expectFinalLog('skipped', 'no rules with gaps'); + expectFinalLog('no_gaps', 'no rules with gaps'); expect(rulesClient.getRuleIdsWithGaps).toHaveBeenCalledWith( expect.objectContaining({ start: expect.any(String), diff --git a/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/task/gap_auto_fill_scheduler_task.ts b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/task/gap_auto_fill_scheduler_task.ts index 1785742d4c2e0..d66b5253a16bc 100644 --- a/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/task/gap_auto_fill_scheduler_task.ts +++ b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/task/gap_auto_fill_scheduler_task.ts @@ -16,14 +16,13 @@ import { processGapsBatch } from '../../../application/gaps/methods/bulk_fill_ga import { GapFillSchedulePerRuleStatus } from '../../../application/gaps/methods/bulk_fill_gaps_by_rule_ids/types'; import type { RulesClientApi } from '../../../types'; -import { gapStatus } from '../../../../common/constants'; +import { gapStatus, GAP_AUTO_FILL_STATUS } from '../../../../common/constants'; import type { createGapAutoFillSchedulerEventLogger } from './gap_auto_fill_scheduler_event_log'; import { GAP_AUTO_FILL_SCHEDULER_TASK_TYPE, DEFAULT_RULES_BATCH_SIZE, DEFAULT_GAPS_PER_PAGE, DEFAULT_GAP_AUTO_FILL_SCHEDULER_TIMEOUT, - GAP_AUTO_FILL_STATUS, } from '../../../application/gaps/types/scheduler'; import { backfillInitiator } from '../../../../common/constants'; import type { RulesClientContext } from '../../../rules_client/types'; @@ -445,7 +444,7 @@ export function registerGapAutoFillSchedulerTask({ if (!ruleIds.length) { await logEvent({ - status: GAP_AUTO_FILL_STATUS.SKIPPED, + status: GAP_AUTO_FILL_STATUS.NO_GAPS, results: [], message: 'Skipped execution: no rules with gaps', }); diff --git a/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/task/utils.test.ts b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/task/utils.test.ts index ce1d66a0a77f0..eb6eab2a110ec 100644 --- a/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/task/utils.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/task/utils.test.ts @@ -11,7 +11,7 @@ import { backfillClientMock } from '../../../backfill_client/backfill_client.moc import { rulesClientMock } from '../../../rules_client.mock'; import type { RulesClientContext } from '../../../rules_client/types'; import { Gap } from '../gap'; -import { GAP_AUTO_FILL_STATUS } from '../../../application/gaps/types/scheduler'; +import { GAP_AUTO_FILL_STATUS } from '../../../../common/constants'; import { resultsFromMap, formatConsolidatedSummary, diff --git a/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/task/utils.ts b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/task/utils.ts index 528cb48fcca21..5d549ae41f5a0 100644 --- a/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/task/utils.ts +++ b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/task/utils.ts @@ -15,14 +15,15 @@ import type { SchedulerSoAttributes, GapAutoFillSchedulerLogConfig, } from '../../../application/gaps/types/scheduler'; -import { backfillInitiator } from '../../../../common/constants'; -import { GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE } from '../../../saved_objects'; -import { createGapAutoFillSchedulerEventLogger } from './gap_auto_fill_scheduler_event_log'; -import type { GapAutoFillSchedulerEventLogger } from './gap_auto_fill_scheduler_event_log'; import { + backfillInitiator, GAP_AUTO_FILL_STATUS, type GapAutoFillStatus, -} from '../../../application/gaps/types/scheduler'; +} from '../../../../common/constants'; +import { GAP_AUTO_FILL_SCHEDULER_SAVED_OBJECT_TYPE } from '../../../saved_objects'; +import { createGapAutoFillSchedulerEventLogger } from './gap_auto_fill_scheduler_event_log'; +import type { GapAutoFillSchedulerEventLogger } from './gap_auto_fill_scheduler_event_log'; + import type { Gap } from '../gap'; import { getOverlap } from '../gap/interval_utils'; import { GapFillSchedulePerRuleStatus } from '../../../application/gaps/methods/bulk_fill_gaps_by_rule_ids/types'; diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/create/create_auto_fill_scheduler_route.ts b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/create/create_auto_fill_scheduler_route.ts index 323b4ea523c08..865405807280f 100644 --- a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/create/create_auto_fill_scheduler_route.ts +++ b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/create/create_auto_fill_scheduler_route.ts @@ -10,8 +10,9 @@ import { gapAutoFillSchedulerBodySchemaV1 } from '../../../../../../common/route import type { ILicenseState } from '../../../../../lib'; import { verifyAccessAndContext } from '../../../../lib'; import type { AlertingRequestHandlerContext } from '../../../../../types'; -import { transformRequestV1, transformResponseV1 } from './transforms'; import { INTERNAL_ALERTING_GAPS_AUTO_FILL_SCHEDULER_API_PATH } from '../../../../../types'; +import { transformRequestV1 } from './transforms'; +import { transformToGapAutoFillSchedulerResponseBodyV1 } from '../transforms/transform_response'; import { DEFAULT_ALERTING_ROUTE_SECURITY } from '../../../../constants'; export const createAutoFillSchedulerRoute = ( @@ -29,11 +30,13 @@ export const createAutoFillSchedulerRoute = ( }, router.handleLegacyErrors( verifyAccessAndContext(licenseState, async function (context, req, res) { + licenseState.ensureLicenseForGapAutoFillScheduler(); + const alertingContext = await context.alerting; const rulesClient = await alertingContext.getRulesClient(); const result = await rulesClient.createGapAutoFillScheduler(transformRequestV1(req)); const response: GapAutoFillSchedulerResponseV1 = { - body: transformResponseV1(result), + body: transformToGapAutoFillSchedulerResponseBodyV1(result), }; return res.ok(response); }) diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/create/transforms/index.ts b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/create/transforms/index.ts index 8ccabddff6b0c..8f003a60a76c1 100644 --- a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/create/transforms/index.ts +++ b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/create/transforms/index.ts @@ -6,4 +6,3 @@ */ export { transformRequest as transformRequestV1 } from './transform_request/v1'; -export { transformResponse as transformResponseV1 } from './transform_response/v1'; diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/delete/delete_auto_fill_scheduler_route.test.ts b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/delete/delete_auto_fill_scheduler_route.test.ts new file mode 100644 index 0000000000000..4efe18d7b8be3 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/delete/delete_auto_fill_scheduler_route.test.ts @@ -0,0 +1,104 @@ +/* + * 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 { httpServiceMock } from '@kbn/core/server/mocks'; +import { licenseStateMock } from '../../../../../lib/license_state.mock'; +import { verifyApiAccess } from '../../../../../lib/license_api_access'; +import { mockHandlerArguments } from '../../../../_mock_handler_arguments'; +import { rulesClientMock } from '../../../../../rules_client.mock'; +import { deleteAutoFillSchedulerRoute } from './delete_auto_fill_scheduler_route'; + +const rulesClient = rulesClientMock.create(); + +jest.mock('../../../../../lib/license_api_access', () => ({ + verifyApiAccess: jest.fn(), +})); + +describe('deleteAutoFillSchedulerRoute', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('should call delete gap fill auto scheduler with proper parameters', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + deleteAutoFillSchedulerRoute(router, licenseState); + + const [config, handler] = router.delete.mock.calls[0]; + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { params: { id: 'test-scheduler-id' } }, + ['noContent'] + ); + + expect(config.path).toEqual('/internal/alerting/rules/gaps/auto_fill_scheduler/{id}'); + + await handler(context, req, res); + + expect(rulesClient.deleteGapAutoFillScheduler).toHaveBeenCalledWith({ + id: 'test-scheduler-id', + }); + + expect(res.noContent).toHaveBeenCalled(); + }); + + test('ensures the license allows for deleting gap fill auto scheduler', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + rulesClient.deleteGapAutoFillScheduler = jest.fn(); + + deleteAutoFillSchedulerRoute(router, licenseState); + + const [, handler] = router.delete.mock.calls[0]; + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { params: { id: 'test-scheduler-id' } } + ); + await handler(context, req, res); + expect(verifyApiAccess).toHaveBeenCalledWith(licenseState); + }); + + test('ensures the license check prevents deleting gap fill auto scheduler when appropriate', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + rulesClient.deleteGapAutoFillScheduler = jest.fn(); + + deleteAutoFillSchedulerRoute(router, licenseState); + + (verifyApiAccess as jest.Mock).mockImplementation(() => { + throw new Error('License check failed'); + }); + const [, handler] = router.delete.mock.calls[0]; + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { params: { id: 'test-scheduler-id' } } + ); + await expect(handler(context, req, res)).rejects.toMatchInlineSnapshot( + `[Error: License check failed]` + ); + }); + + test('handles validation for params', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + deleteAutoFillSchedulerRoute(router, licenseState); + + const [config] = router.delete.mock.calls[0]; + expect(config.validate).toBeDefined(); + if ( + config.validate && + typeof config.validate !== 'boolean' && + typeof config.validate !== 'function' + ) { + expect(config.validate.params).toBeDefined(); + } + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/delete/delete_auto_fill_scheduler_route.ts b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/delete/delete_auto_fill_scheduler_route.ts new file mode 100644 index 0000000000000..a9d3bf9409401 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/delete/delete_auto_fill_scheduler_route.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { IRouter } from '@kbn/core/server'; +import type { ILicenseState } from '../../../../../lib'; +import { verifyAccessAndContext } from '../../../../lib'; +import type { AlertingRequestHandlerContext } from '../../../../../types'; +import { INTERNAL_ALERTING_GAPS_AUTO_FILL_SCHEDULER_API_PATH } from '../../../../../types'; +import { getGapAutoFillSchedulerParamsSchemaV1 } from '../../../../../../common/routes/gaps/apis/gap_auto_fill_scheduler'; +import type { GetGapAutoFillSchedulerParamsV1 } from '../../../../../../common/routes/gaps/apis/gap_auto_fill_scheduler'; +import { DEFAULT_ALERTING_ROUTE_SECURITY } from '../../../../constants'; + +export const deleteAutoFillSchedulerRoute = ( + router: IRouter, + licenseState: ILicenseState +) => { + router.delete( + { + path: `${INTERNAL_ALERTING_GAPS_AUTO_FILL_SCHEDULER_API_PATH}/{id}`, + security: DEFAULT_ALERTING_ROUTE_SECURITY, + options: { access: 'internal' }, + validate: { + params: getGapAutoFillSchedulerParamsSchemaV1, + }, + }, + router.handleLegacyErrors( + verifyAccessAndContext(licenseState, async function (context, req, res) { + licenseState.ensureLicenseForGapAutoFillScheduler(); + + const params: GetGapAutoFillSchedulerParamsV1 = req.params; + const alertingContext = await context.alerting; + const rulesClient = await alertingContext.getRulesClient(); + await rulesClient.deleteGapAutoFillScheduler({ id: params.id }); + return res.noContent(); + }) + ) + ); +}; diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/get/get_auto_fill_scheduler_route.test.ts b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/get/get_auto_fill_scheduler_route.test.ts new file mode 100644 index 0000000000000..30b8c806c2199 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/get/get_auto_fill_scheduler_route.test.ts @@ -0,0 +1,116 @@ +/* + * 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 { httpServiceMock } from '@kbn/core/server/mocks'; +import { licenseStateMock } from '../../../../../lib/license_state.mock'; +import { verifyApiAccess } from '../../../../../lib/license_api_access'; +import { mockHandlerArguments } from '../../../../_mock_handler_arguments'; +import { rulesClientMock } from '../../../../../rules_client.mock'; +import { getAutoFillSchedulerRoute } from './get_auto_fill_scheduler_route'; + +const rulesClient = rulesClientMock.create(); + +jest.mock('../../../../../lib/license_api_access', () => ({ + verifyApiAccess: jest.fn(), +})); + +describe('getAutoFillSchedulerRoute', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + const mockGetResponse = { + id: 'test-scheduler-id', + name: 'Test Scheduler', + enabled: true, + schedule: { interval: '1h' }, + gapFillRange: '24h', + maxBackfills: 100, + numRetries: 3, + createdBy: 'test-user', + updatedBy: 'test-user', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + scheduledTaskId: 'task-id', + scope: ['test-space'], + ruleTypes: [{ type: 'test-type', consumer: 'test-consumer' }], + }; + + test('should call get gap fill auto scheduler with proper parameters', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + getAutoFillSchedulerRoute(router, licenseState); + + rulesClient.getGapAutoFillScheduler.mockResolvedValueOnce(mockGetResponse); + const [config, handler] = router.get.mock.calls[0]; + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { params: { id: 'test-scheduler-id' } }, + ['ok'] + ); + + expect(config.path).toEqual('/internal/alerting/rules/gaps/auto_fill_scheduler/{id}'); + + await handler(context, req, res); + + expect(rulesClient.getGapAutoFillScheduler).toHaveBeenCalledWith({ id: 'test-scheduler-id' }); + + expect(res.ok).toHaveBeenCalledWith({ + body: { + id: 'test-scheduler-id', + name: 'Test Scheduler', + enabled: true, + schedule: { interval: '1h' }, + gap_fill_range: '24h', + max_backfills: 100, + num_retries: 3, + scope: ['test-space'], + rule_types: [{ type: 'test-type', consumer: 'test-consumer' }], + created_by: 'test-user', + updated_by: 'test-user', + created_at: '2024-01-01T00:00:00.000Z', + updated_at: '2024-01-01T00:00:00.000Z', + }, + }); + }); + + test('ensures the license allows for getting gap fill auto scheduler', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + getAutoFillSchedulerRoute(router, licenseState); + + rulesClient.getGapAutoFillScheduler.mockResolvedValueOnce(mockGetResponse); + const [, handler] = router.get.mock.calls[0]; + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { params: { id: 'test-scheduler-id' } } + ); + await handler(context, req, res); + expect(verifyApiAccess).toHaveBeenCalledWith(licenseState); + }); + + test('ensures the license check prevents getting gap fill auto scheduler when appropriate', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + getAutoFillSchedulerRoute(router, licenseState); + + (verifyApiAccess as jest.Mock).mockImplementation(() => { + throw new Error('License check failed'); + }); + const [, handler] = router.get.mock.calls[0]; + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { params: { id: 'test-scheduler-id' } } + ); + await expect(handler(context, req, res)).rejects.toMatchInlineSnapshot( + `[Error: License check failed]` + ); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/get/get_auto_fill_scheduler_route.ts b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/get/get_auto_fill_scheduler_route.ts new file mode 100644 index 0000000000000..9a2a55230da4a --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/get/get_auto_fill_scheduler_route.ts @@ -0,0 +1,50 @@ +/* + * 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 { IRouter } from '@kbn/core/server'; +import type { + GapAutoFillSchedulerResponseV1, + GetGapAutoFillSchedulerParamsV1, +} from '../../../../../../common/routes/gaps/apis/gap_auto_fill_scheduler'; +import { getGapAutoFillSchedulerParamsSchemaV1 } from '../../../../../../common/routes/gaps/apis/gap_auto_fill_scheduler'; +import type { ILicenseState } from '../../../../../lib'; +import { verifyAccessAndContext } from '../../../../lib'; +import type { AlertingRequestHandlerContext } from '../../../../../types'; +import { INTERNAL_ALERTING_GAPS_AUTO_FILL_SCHEDULER_API_PATH } from '../../../../../types'; +import { transformRequestV1 } from './transforms'; +import { transformToGapAutoFillSchedulerResponseBodyV1 } from '../transforms/transform_response'; +import { DEFAULT_ALERTING_ROUTE_SECURITY } from '../../../../constants'; + +export const getAutoFillSchedulerRoute = ( + router: IRouter, + licenseState: ILicenseState +) => { + router.get( + { + path: `${INTERNAL_ALERTING_GAPS_AUTO_FILL_SCHEDULER_API_PATH}/{id}`, + security: DEFAULT_ALERTING_ROUTE_SECURITY, + options: { access: 'internal' }, + validate: { + params: getGapAutoFillSchedulerParamsSchemaV1, + }, + }, + router.handleLegacyErrors( + verifyAccessAndContext(licenseState, async function (context, req, res) { + licenseState.ensureLicenseForGapAutoFillScheduler(); + + const params: GetGapAutoFillSchedulerParamsV1 = req.params; + + const alertingContext = await context.alerting; + const rulesClient = await alertingContext.getRulesClient(); + const result = await rulesClient.getGapAutoFillScheduler(transformRequestV1(params)); + const response: GapAutoFillSchedulerResponseV1 = { + body: transformToGapAutoFillSchedulerResponseBodyV1(result), + }; + return res.ok(response); + }) + ) + ); +}; diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/get/transforms/index.ts b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/get/transforms/index.ts new file mode 100644 index 0000000000000..0a2acaa1895b0 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/get/transforms/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { transformRequest } from './transform_request/latest'; +export { transformRequest as transformRequestV1 } from './transform_request/v1'; diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/get/transforms/transform_request/latest.ts b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/get/transforms/transform_request/latest.ts new file mode 100644 index 0000000000000..0b2ce39ab3eac --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/get/transforms/transform_request/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { transformRequest } from './v1'; diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/get/transforms/transform_request/v1.ts b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/get/transforms/transform_request/v1.ts new file mode 100644 index 0000000000000..13995446030a4 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/get/transforms/transform_request/v1.ts @@ -0,0 +1,15 @@ +/* + * 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 { GetGapAutoFillSchedulerParamsV1 } from '../../../../../../../../common/routes/gaps/apis/gap_auto_fill_scheduler'; +import type { GetGapAutoFillSchedulerParams } from '../../../../../../../application/gap_auto_fill_scheduler/methods/get/types'; + +export const transformRequest = ( + params: GetGapAutoFillSchedulerParamsV1 +): GetGapAutoFillSchedulerParams => ({ + id: params.id, +}); diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/logs/get_auto_fill_scheduler_logs_route.test.ts b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/logs/get_auto_fill_scheduler_logs_route.test.ts new file mode 100644 index 0000000000000..76dce0236772e --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/logs/get_auto_fill_scheduler_logs_route.test.ts @@ -0,0 +1,134 @@ +/* + * 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 { httpServiceMock } from '@kbn/core/server/mocks'; +import { licenseStateMock } from '../../../../../lib/license_state.mock'; +import { verifyApiAccess } from '../../../../../lib/license_api_access'; +import { mockHandlerArguments } from '../../../../_mock_handler_arguments'; +import { rulesClientMock } from '../../../../../rules_client.mock'; +import { getAutoFillSchedulerLogsRoute } from './get_auto_fill_scheduler_logs_route'; + +const rulesClient = rulesClientMock.create(); + +jest.mock('../../../../../lib/license_api_access', () => ({ + verifyApiAccess: jest.fn(), +})); + +describe('getAutoFillSchedulerLogsRoute', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + const mockGetLogsResponse = { + data: [ + { + id: 'test-log-id', + timestamp: '2024-01-01T00:00:00.000Z', + status: 'success', + message: 'Gap fill auto scheduler logs', + results: [ + { + ruleId: 'test-rule-id', + processedGaps: 10, + status: 'success', + }, + ], + }, + ], + total: 1, + page: 1, + perPage: 10, + }; + + test('should call get gap auto fill scheduler logs with proper parameters', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + getAutoFillSchedulerLogsRoute(router, licenseState); + rulesClient.getGapAutoFillSchedulerLogs.mockResolvedValueOnce(mockGetLogsResponse); + const [config, handler] = router.post.mock.calls[0]; + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + params: { id: 'test-scheduler-id' }, + body: { + start: '2024-01-01T00:00:00.000Z', + end: '2024-01-01T00:00:00.000Z', + page: 1, + per_page: 10, + sort_field: '@timestamp', + sort_direction: 'desc', + statuses: ['success'], + }, + }, + ['ok'] + ); + expect(config.path).toEqual('/internal/alerting/rules/gaps/auto_fill_scheduler/{id}/logs'); + await handler(context, req, res); + expect(rulesClient.getGapAutoFillSchedulerLogs).toHaveBeenCalledWith({ + id: 'test-scheduler-id', + start: '2024-01-01T00:00:00.000Z', + end: '2024-01-01T00:00:00.000Z', + page: 1, + perPage: 10, + sortField: '@timestamp', + sortDirection: 'desc', + statuses: ['success'], + }); + expect(res.ok).toHaveBeenCalledWith({ + body: { + data: [ + { + id: 'test-log-id', + timestamp: '2024-01-01T00:00:00.000Z', + status: 'success', + message: 'Gap fill auto scheduler logs', + results: [ + { + ruleId: 'test-rule-id', + processed_gaps: 10, + status: 'success', + }, + ], + }, + ], + total: 1, + page: 1, + per_page: 10, + }, + }); + }); + + test('ensures the license allows for getting gap auto fill scheduler logs', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + getAutoFillSchedulerLogsRoute(router, licenseState); + const [, handler] = router.post.mock.calls[0]; + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { params: { id: 'test-scheduler-id' } } + ); + await handler(context, req, res); + expect(verifyApiAccess).toHaveBeenCalledWith(licenseState); + }); + + test('ensures the license check prevents getting gap auto fill scheduler logs when appropriate', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + getAutoFillSchedulerLogsRoute(router, licenseState); + const [, handler] = router.post.mock.calls[0]; + (verifyApiAccess as jest.Mock).mockImplementation(() => { + throw new Error('License check failed'); + }); + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { params: { id: 'test-scheduler-id' } } + ); + await expect(handler(context, req, res)).rejects.toMatchInlineSnapshot( + `[Error: License check failed]` + ); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/logs/get_auto_fill_scheduler_logs_route.ts b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/logs/get_auto_fill_scheduler_logs_route.ts new file mode 100644 index 0000000000000..e24a698280f57 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/logs/get_auto_fill_scheduler_logs_route.ts @@ -0,0 +1,67 @@ +/* + * 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 { IRouter } from '@kbn/core/server'; +import { schema } from '@kbn/config-schema'; +import type { GapAutoFillSchedulerLogsResponseV1 } from '../../../../../../common/routes/gaps/apis/gap_auto_fill_scheduler'; +import { gapAutoFillSchedulerLogsRequestQuerySchema } from '../../../../../../common/routes/gaps/apis/gap_auto_fill_scheduler'; +import type { ILicenseState } from '../../../../../lib'; +import { verifyAccessAndContext } from '../../../../lib'; +import type { AlertingRequestHandlerContext } from '../../../../../types'; +import { INTERNAL_ALERTING_GAPS_AUTO_FILL_SCHEDULER_API_PATH } from '../../../../../types'; +import { transformRequestV1, transformResponseV1 } from './transforms'; +import { DEFAULT_ALERTING_ROUTE_SECURITY } from '../../../../constants'; + +export const getAutoFillSchedulerLogsRoute = ( + router: IRouter, + licenseState: ILicenseState +) => { + router.post( + { + path: `${INTERNAL_ALERTING_GAPS_AUTO_FILL_SCHEDULER_API_PATH}/{id}/logs`, + security: DEFAULT_ALERTING_ROUTE_SECURITY, + options: { access: 'internal' }, + validate: { + params: schema.object({ + id: schema.string(), + }), + body: gapAutoFillSchedulerLogsRequestQuerySchema, + }, + }, + router.handleLegacyErrors( + verifyAccessAndContext(licenseState, async function (context, req, res) { + licenseState.ensureLicenseForGapAutoFillScheduler(); + + try { + const { id } = req.params as { id: string }; + const body = req.body; + const alertingContext = await context.alerting; + const rulesClient = await alertingContext.getRulesClient(); + + const params = transformRequestV1(id, body); + + const result = await rulesClient.getGapAutoFillSchedulerLogs(params); + + const response: GapAutoFillSchedulerLogsResponseV1 = { + body: transformResponseV1(result), + }; + + return res.ok(response); + } catch (error) { + if (error?.output?.statusCode === 404) { + return res.notFound({ + body: { message: `Gap fill auto scheduler with id ${req.params.id} not found` }, + }); + } + return res.customError({ + statusCode: error?.output?.statusCode || 500, + body: { message: error.message || 'Error fetching gap fill event logs' }, + }); + } + }) + ) + ); +}; diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/logs/transforms/index.ts b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/logs/transforms/index.ts new file mode 100644 index 0000000000000..8ccabddff6b0c --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/logs/transforms/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { transformRequest as transformRequestV1 } from './transform_request/v1'; +export { transformResponse as transformResponseV1 } from './transform_response/v1'; diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/logs/transforms/transform_request/latest.ts b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/logs/transforms/transform_request/latest.ts new file mode 100644 index 0000000000000..b3ca114c4acc1 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/logs/transforms/transform_request/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { transformRequest as transformRequestV1 } from './v1'; diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/logs/transforms/transform_request/v1.ts b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/logs/transforms/transform_request/v1.ts new file mode 100644 index 0000000000000..5cd1426902d34 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/logs/transforms/transform_request/v1.ts @@ -0,0 +1,23 @@ +/* + * 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 { GapAutoFillSchedulerLogsRequestQueryV1 } from '../../../../../../../../common/routes/gaps/apis/gap_auto_fill_scheduler'; +import type { GetGapAutoFillSchedulerLogsParams } from '../../../../../../../application/gap_auto_fill_scheduler/methods/get_logs/types/get_gap_auto_fill_scheduler_logs_types'; + +export const transformRequest = ( + id: string, + query: GapAutoFillSchedulerLogsRequestQueryV1 +): GetGapAutoFillSchedulerLogsParams => ({ + id, + start: query.start, + end: query.end, + page: query.page, + perPage: query.per_page, + sortField: query.sort_field, + sortDirection: query.sort_direction, + statuses: query.statuses, +}); diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/create/transforms/transform_response/latest.ts b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/logs/transforms/transform_response/latest.ts similarity index 100% rename from x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/create/transforms/transform_response/latest.ts rename to x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/logs/transforms/transform_response/latest.ts diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/logs/transforms/transform_response/v1.ts b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/logs/transforms/transform_response/v1.ts new file mode 100644 index 0000000000000..4662349f96078 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/logs/transforms/transform_response/v1.ts @@ -0,0 +1,29 @@ +/* + * 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 { GapAutoFillSchedulerLogsResponseBodyV1 } from '../../../../../../../../common/routes/gaps/apis/gap_auto_fill_scheduler'; +import type { GapAutoFillSchedulerLogsResult } from '../../../../../../../application/gap_auto_fill_scheduler/methods/get_logs/types/get_gap_auto_fill_scheduler_logs_types'; + +export const transformResponse = ( + result: GapAutoFillSchedulerLogsResult +): GapAutoFillSchedulerLogsResponseBodyV1 => ({ + data: result.data.map((entry) => ({ + id: entry.id, + status: entry.status, + message: entry.message, + timestamp: entry.timestamp, + results: entry.results?.map((ruleResult) => ({ + ruleId: ruleResult.ruleId, + processed_gaps: ruleResult.processedGaps, + status: ruleResult.status, + error: ruleResult.error, + })), + })), + total: result.total, + page: result.page, + per_page: result.perPage, +}); diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/transforms/transform_response/index.ts b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/transforms/transform_response/index.ts new file mode 100644 index 0000000000000..4b21c82f59e8e --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/transforms/transform_response/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { transformToGapAutoFillSchedulerResponseBody as transformToGapAutoFillSchedulerResponseBodyV1 } from './v1'; diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/create/transforms/transform_response/v1.test.ts b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/transforms/transform_response/v1.test.ts similarity index 50% rename from x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/create/transforms/transform_response/v1.test.ts rename to x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/transforms/transform_response/v1.test.ts index 6ff260b472bdb..4bbde5c16f7a2 100644 --- a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/create/transforms/transform_response/v1.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/transforms/transform_response/v1.test.ts @@ -5,29 +5,28 @@ * 2.0. */ -import { transformResponse } from './v1'; -import type { GapAutoFillSchedulerResponse } from '../../../../../../../application/gap_auto_fill_scheduler/result/types'; +import type { GapAutoFillSchedulerResponse } from '../../../../../../application/gap_auto_fill_scheduler/result/types'; +import { transformToGapAutoFillSchedulerResponseBody } from './v1'; -describe('transformResponse v1 - create', () => { - it('should transform create response correctly', () => { - const mockResponse: GapAutoFillSchedulerResponse = { - id: 'test-scheduler-id', - name: 'Test Scheduler', - enabled: true, - schedule: { interval: '1h' }, - gapFillRange: '24h', - maxBackfills: 100, - numRetries: 3, - createdBy: 'test-user', - updatedBy: 'test-user', - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-01T00:00:00.000Z', - ruleTypes: [{ type: 'test-rule-type', consumer: 'test-consumer' }], - scope: ['internal'], - }; - - const result = transformResponse(mockResponse); +describe('transformToGapAutoFillSchedulerResponseBody', () => { + const mockResult: GapAutoFillSchedulerResponse = { + id: 'test-scheduler-id', + name: 'Test Scheduler', + enabled: true, + schedule: { interval: '1h' }, + gapFillRange: '24h', + maxBackfills: 100, + numRetries: 3, + createdBy: 'test-user', + updatedBy: 'test-user', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + ruleTypes: [{ type: 'test-rule-type', consumer: 'test-consumer' }], + scope: ['internal'], + }; + it('transforms scheduler correctly to V1 response body', () => { + const result = transformToGapAutoFillSchedulerResponseBody(mockResult); expect(result).toEqual({ id: 'test-scheduler-id', name: 'Test Scheduler', diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/create/transforms/transform_response/v1.ts b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/transforms/transform_response/v1.ts similarity index 82% rename from x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/create/transforms/transform_response/v1.ts rename to x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/transforms/transform_response/v1.ts index af189e5d78fb8..86325f9019b6b 100644 --- a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/create/transforms/transform_response/v1.ts +++ b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/transforms/transform_response/v1.ts @@ -4,11 +4,10 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import type { GapAutoFillSchedulerResponseBodyV1 } from '../../../../../../../common/routes/gaps/apis/gap_auto_fill_scheduler'; +import type { GapAutoFillSchedulerResponse } from '../../../../../../application/gap_auto_fill_scheduler/result/types'; -import type { GapAutoFillSchedulerResponseBodyV1 } from '../../../../../../../../common/routes/gaps/apis/gap_auto_fill_scheduler'; -import type { GapAutoFillSchedulerResponse } from '../../../../../../../application/gap_auto_fill_scheduler/result/types'; - -export const transformResponse = ( +export const transformToGapAutoFillSchedulerResponseBody = ( result: GapAutoFillSchedulerResponse ): GapAutoFillSchedulerResponseBodyV1 => ({ id: result.id, diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/update/transforms/index.ts b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/update/transforms/index.ts new file mode 100644 index 0000000000000..8f003a60a76c1 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/update/transforms/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { transformRequest as transformRequestV1 } from './transform_request/v1'; diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/update/transforms/transform_request/v1.test.ts b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/update/transforms/transform_request/v1.test.ts new file mode 100644 index 0000000000000..4d062f2e70c6f --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/update/transforms/transform_request/v1.test.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { httpServerMock } from '@kbn/core/server/mocks'; +import { transformRequest } from './v1'; + +describe('transformRequest()', () => { + it('converts snake_case payload to camelCase update params', () => { + const request = httpServerMock.createKibanaRequest({ + method: 'put', + path: '/internal/alerting/rules/gaps/auto_fill_scheduler/scheduler-1', + params: { + id: 'scheduler-1', + }, + body: { + name: 'scheduler', + enabled: false, + gap_fill_range: 'now-30d', + max_backfills: 200, + num_retries: 5, + schedule: { interval: '1h' }, + scope: ['scope-a'], + rule_types: [{ type: 'rule-type', consumer: 'alertsFixture' }], + }, + }); + + const result = transformRequest(request); + + expect(result).toEqual({ + id: 'scheduler-1', + name: 'scheduler', + enabled: false, + gapFillRange: 'now-30d', + maxBackfills: 200, + numRetries: 5, + schedule: { interval: '1h' }, + scope: ['scope-a'], + ruleTypes: [{ type: 'rule-type', consumer: 'alertsFixture' }], + request, + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/update/transforms/transform_request/v1.ts b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/update/transforms/transform_request/v1.ts new file mode 100644 index 0000000000000..5d8f53798c47b --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/update/transforms/transform_request/v1.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { KibanaRequest } from '@kbn/core/server'; +import type { + GetGapAutoFillSchedulerParamsV1, + UpdateGapAutoFillSchedulerRequestBodyV1, +} from '../../../../../../../../common/routes/gaps/apis/gap_auto_fill_scheduler'; +import type { UpdateGapAutoFillSchedulerParams } from '../../../../../../../application/gap_auto_fill_scheduler/methods/update/types'; + +export const transformRequest = ( + request: KibanaRequest< + GetGapAutoFillSchedulerParamsV1, + unknown, + UpdateGapAutoFillSchedulerRequestBodyV1, + 'put' + > +): UpdateGapAutoFillSchedulerParams => { + const body = request.body; + const params = request.params; + + return { + id: params.id, + request, + name: body.name, + enabled: body.enabled, + gapFillRange: body.gap_fill_range, + maxBackfills: body.max_backfills, + numRetries: body.num_retries, + schedule: body.schedule, + scope: body.scope, + ruleTypes: body.rule_types.map((ruleType) => ({ + type: ruleType.type, + consumer: ruleType.consumer, + })), + }; +}; diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/update/update_auto_fill_scheduler_route.test.ts b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/update/update_auto_fill_scheduler_route.test.ts new file mode 100644 index 0000000000000..d830f1993e853 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/update/update_auto_fill_scheduler_route.test.ts @@ -0,0 +1,169 @@ +/* + * 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 { httpServiceMock } from '@kbn/core/server/mocks'; +import { licenseStateMock } from '../../../../../lib/license_state.mock'; +import { verifyApiAccess } from '../../../../../lib/license_api_access'; +import { mockHandlerArguments } from '../../../../_mock_handler_arguments'; +import { rulesClientMock } from '../../../../../rules_client.mock'; +import { updateAutoFillSchedulerRoute } from './update_auto_fill_scheduler_route'; + +const rulesClient = rulesClientMock.create(); + +jest.mock('../../../../../lib/license_api_access', () => ({ + verifyApiAccess: jest.fn(), +})); + +describe('updateAutoFillSchedulerRoute', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + const mockUpdateRequest = { + name: 'Updated Scheduler', + enabled: false, + schedule: { interval: '30m' }, + gap_fill_range: 'now-30d', + max_backfills: 50, + num_retries: 2, + scope: ['scope-a'], + rule_types: [{ type: 'test-rule-type', consumer: 'test-consumer' }], + }; + + const mockUpdateResponse = { + id: 'test-scheduler-id', + name: 'Updated Scheduler', + enabled: false, + schedule: { interval: '30m' }, + gapFillRange: 'now-30d', + maxBackfills: 50, + numRetries: 2, + scope: ['scope-a'], + ruleTypes: [{ type: 'test-rule-type', consumer: 'test-consumer' }], + createdBy: 'user', + updatedBy: 'user', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }; + + test('calls update gap auto fill scheduler with transformed params', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + updateAutoFillSchedulerRoute(router, licenseState); + + rulesClient.updateGapAutoFillScheduler.mockResolvedValueOnce(mockUpdateResponse); + const [config, handler] = router.put.mock.calls[0]; + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + params: { id: 'test-scheduler-id' }, + body: mockUpdateRequest, + } + ); + + expect(config.path).toEqual('/internal/alerting/rules/gaps/auto_fill_scheduler/{id}'); + + await handler(context, req, res); + + expect(rulesClient.updateGapAutoFillScheduler).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'test-scheduler-id', + name: 'Updated Scheduler', + enabled: false, + schedule: { interval: '30m' }, + gapFillRange: 'now-30d', + maxBackfills: 50, + numRetries: 2, + scope: ['scope-a'], + ruleTypes: [{ type: 'test-rule-type', consumer: 'test-consumer' }], + request: expect.any(Object), + }) + ); + + expect(res.ok).toHaveBeenCalledWith({ + body: { + id: 'test-scheduler-id', + name: 'Updated Scheduler', + enabled: false, + schedule: { interval: '30m' }, + gap_fill_range: 'now-30d', + max_backfills: 50, + num_retries: 2, + created_by: 'user', + updated_by: 'user', + created_at: '2024-01-01T00:00:00.000Z', + updated_at: '2024-01-02T00:00:00.000Z', + scope: ['scope-a'], + rule_types: [{ type: 'test-rule-type', consumer: 'test-consumer' }], + }, + }); + }); + + test('ensures the license allows for updating gap auto fill scheduler', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + updateAutoFillSchedulerRoute(router, licenseState); + + rulesClient.updateGapAutoFillScheduler.mockResolvedValueOnce(mockUpdateResponse); + const [, handler] = router.put.mock.calls[0]; + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + params: { id: 'test-scheduler-id' }, + body: mockUpdateRequest, + } + ); + + await handler(context, req, res); + + expect(verifyApiAccess).toHaveBeenCalledWith(licenseState); + expect(licenseState.ensureLicenseForGapAutoFillScheduler).toHaveBeenCalled(); + }); + + test('respects license failures', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + updateAutoFillSchedulerRoute(router, licenseState); + + (verifyApiAccess as jest.Mock).mockImplementation(() => { + throw new Error('License check failed'); + }); + const [, handler] = router.put.mock.calls[0]; + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + params: { id: 'test-scheduler-id' }, + body: mockUpdateRequest, + } + ); + + await expect(handler(context, req, res)).rejects.toMatchInlineSnapshot( + `[Error: License check failed]` + ); + }); + + test('includes validation schemas', () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + updateAutoFillSchedulerRoute(router, licenseState); + + const [config] = router.put.mock.calls[0]; + expect(config.validate).toBeDefined(); + if ( + config.validate && + typeof config.validate !== 'boolean' && + typeof config.validate !== 'function' + ) { + expect(config.validate.body).toBeDefined(); + expect(config.validate.params).toBeDefined(); + } + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/update/update_auto_fill_scheduler_route.ts b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/update/update_auto_fill_scheduler_route.ts new file mode 100644 index 0000000000000..7a4ace6d145e7 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/gap_auto_fill_schedule/update/update_auto_fill_scheduler_route.ts @@ -0,0 +1,50 @@ +/* + * 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 { IRouter } from '@kbn/core/server'; +import type { GapAutoFillSchedulerResponseV1 } from '../../../../../../common/routes/gaps/apis/gap_auto_fill_scheduler'; +import { + gapAutoFillSchedulerUpdateBodySchemaV1, + getGapAutoFillSchedulerParamsSchemaV1, +} from '../../../../../../common/routes/gaps/apis/gap_auto_fill_scheduler'; +import type { ILicenseState } from '../../../../../lib'; +import { verifyAccessAndContext } from '../../../../lib'; +import type { AlertingRequestHandlerContext } from '../../../../../types'; +import { INTERNAL_ALERTING_GAPS_AUTO_FILL_SCHEDULER_API_PATH } from '../../../../../types'; +import { transformToGapAutoFillSchedulerResponseBodyV1 } from '../transforms/transform_response'; +import { transformRequestV1 } from './transforms'; +import { DEFAULT_ALERTING_ROUTE_SECURITY } from '../../../../constants'; + +export const updateAutoFillSchedulerRoute = ( + router: IRouter, + licenseState: ILicenseState +) => { + router.put( + { + path: `${INTERNAL_ALERTING_GAPS_AUTO_FILL_SCHEDULER_API_PATH}/{id}`, + security: DEFAULT_ALERTING_ROUTE_SECURITY, + options: { access: 'internal' }, + validate: { + params: getGapAutoFillSchedulerParamsSchemaV1, + body: gapAutoFillSchedulerUpdateBodySchemaV1, + }, + }, + router.handleLegacyErrors( + verifyAccessAndContext(licenseState, async function (context, req, res) { + licenseState.ensureLicenseForGapAutoFillScheduler(); + + const alertingContext = await context.alerting; + const rulesClient = await alertingContext.getRulesClient(); + const result = await rulesClient.updateGapAutoFillScheduler(transformRequestV1(req)); + const response: GapAutoFillSchedulerResponseV1 = { + body: transformToGapAutoFillSchedulerResponseBodyV1(result), + }; + return res.ok(response); + }) + ) + ); +}; diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/index.ts b/x-pack/platform/plugins/shared/alerting/server/routes/index.ts index f0e20a4cc9abd..cea64ec5cdd6b 100644 --- a/x-pack/platform/plugins/shared/alerting/server/routes/index.ts +++ b/x-pack/platform/plugins/shared/alerting/server/routes/index.ts @@ -74,6 +74,10 @@ import { fillGapByIdRoute } from './gaps/apis/fill/fill_gap_by_id_route'; import { getRuleIdsWithGapsRoute } from './gaps/apis/get_rule_ids_with_gaps/get_rule_ids_with_gaps_route'; import { getGapsSummaryByRuleIdsRoute } from './gaps/apis/get_gaps_summary_by_rule_ids/get_gaps_summary_by_rule_ids_route'; import { createAutoFillSchedulerRoute } from './gaps/apis/gap_auto_fill_schedule/create/create_auto_fill_scheduler_route'; +import { getAutoFillSchedulerRoute } from './gaps/apis/gap_auto_fill_schedule/get/get_auto_fill_scheduler_route'; +import { updateAutoFillSchedulerRoute } from './gaps/apis/gap_auto_fill_schedule/update/update_auto_fill_scheduler_route'; +import { deleteAutoFillSchedulerRoute } from './gaps/apis/gap_auto_fill_schedule/delete/delete_auto_fill_scheduler_route'; +import { getAutoFillSchedulerLogsRoute } from './gaps/apis/gap_auto_fill_schedule/logs/get_auto_fill_scheduler_logs_route'; import { getGlobalExecutionSummaryRoute } from './get_global_execution_summary'; import type { AlertingPluginsStart } from '../plugin'; import { getInternalRuleTemplateRoute } from './rule_templates/apis/get/get_rule_template_route'; @@ -159,6 +163,10 @@ export function defineRoutes(opts: RouteOptions) { if (alertingConfig?.gapAutoFillScheduler?.enabled) { createAutoFillSchedulerRoute(router, licenseState); + getAutoFillSchedulerRoute(router, licenseState); + updateAutoFillSchedulerRoute(router, licenseState); + deleteAutoFillSchedulerRoute(router, licenseState); + getAutoFillSchedulerLogsRoute(router, licenseState); } // Rules Settings APIs diff --git a/x-pack/platform/plugins/shared/alerting/server/rules_client.mock.ts b/x-pack/platform/plugins/shared/alerting/server/rules_client.mock.ts index 7a6cbf1ef1f6a..ece9bdec4f841 100644 --- a/x-pack/platform/plugins/shared/alerting/server/rules_client.mock.ts +++ b/x-pack/platform/plugins/shared/alerting/server/rules_client.mock.ts @@ -67,6 +67,10 @@ const createRulesClientMock = () => { getRuleTypesByQuery: jest.fn(), getTemplate: jest.fn(), createGapAutoFillScheduler: jest.fn(), + getGapAutoFillScheduler: jest.fn(), + updateGapAutoFillScheduler: jest.fn(), + deleteGapAutoFillScheduler: jest.fn(), + getGapAutoFillSchedulerLogs: jest.fn(), getContext: jest.fn(), }; return mocked; diff --git a/x-pack/platform/plugins/shared/alerting/server/rules_client/common/audit_events.ts b/x-pack/platform/plugins/shared/alerting/server/rules_client/common/audit_events.ts index 27538a42e3bb0..d1e7e7ba85aa2 100644 --- a/x-pack/platform/plugins/shared/alerting/server/rules_client/common/audit_events.ts +++ b/x-pack/platform/plugins/shared/alerting/server/rules_client/common/audit_events.ts @@ -60,6 +60,7 @@ export enum GapAutoFillSchedulerAuditAction { GET = 'gap_auto_fill_scheduler_get', UPDATE = 'gap_auto_fill_scheduler_update', DELETE = 'gap_auto_fill_scheduler_delete', + GET_LOGS = 'gap_auto_fill_scheduler_get_logs', } export interface GapAutoFillSchedulerAuditEventParams { @@ -304,6 +305,11 @@ const gapAutoFillSchedulerEventVerbs: Record createGapAutoFillScheduler(this.context, params); + public getGapAutoFillScheduler = (params: GetGapAutoFillSchedulerParams) => + getGapAutoFillScheduler(this.context, params); + + public updateGapAutoFillScheduler = (params: UpdateGapAutoFillSchedulerParams) => + updateGapAutoFillScheduler(this.context, params); + + public deleteGapAutoFillScheduler = (params: GetGapAutoFillSchedulerParams) => + deleteGapAutoFillScheduler(this.context, params); + + public getGapAutoFillSchedulerLogs = (params: GetGapAutoFillSchedulerLogsParams) => + getGapAutoFillSchedulerLogs(this.context, params); + public getContext() { return this.context; } diff --git a/x-pack/platform/test/alerting_api_integration/security_and_spaces/group8/tests/alerting/gap/delete_gap_auto_fill_scheduler.ts b/x-pack/platform/test/alerting_api_integration/security_and_spaces/group8/tests/alerting/gap/delete_gap_auto_fill_scheduler.ts new file mode 100644 index 0000000000000..79d3dc6d36c07 --- /dev/null +++ b/x-pack/platform/test/alerting_api_integration/security_and_spaces/group8/tests/alerting/gap/delete_gap_auto_fill_scheduler.ts @@ -0,0 +1,235 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import moment from 'moment'; +import { UserAtSpaceScenarios } from '../../../../scenarios'; +import type { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { getUrlPrefix, ObjectRemover, getTestRuleData } from '../../../../../common/lib'; +import { getFindGaps } from './utils'; + +export default function deleteGapAutoFillSchedulerTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const logger = getService('log'); + const retry = getService('retry'); + + describe('gap auto fill scheduler - delete and cleanup backfills', () => { + const objectRemover = new ObjectRemover(supertest); + const findGaps = getFindGaps({ supertest, logger }); + + function getRule(overwrites: Record = {}) { + return getTestRuleData({ + rule_type_id: 'test.patternFiringAutoRecoverFalse', + params: { + pattern: { + instance: [true, false, true], + }, + }, + schedule: { interval: '6h' }, + ...overwrites, + }); + } + + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + describe(scenario.id, () => { + const apiOptions = { + spaceId: space.id, + username: user.username, + password: user.password, + }; + + afterEach(async () => { + await objectRemover.removeAll(); + await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/_test/gap_auto_fill_scheduler/_delete_all`) + .set('kbn-xsrf', 'foo') + .send({}); + }); + + it('deletes scheduler and removes system backfills (authorized scenarios)', async () => { + if ( + ![ + 'superuser at space1', + 'space_1_all at space1', + 'space_1_all_alerts_none_actions at space1', + 'space_1_all_with_restricted_fixture at space1', + ].includes(scenario.id) + ) { + // Crearte a scheduler by superuser and delete it by unauthorized user + const createSchedulerResp = await supertest + .post( + `${getUrlPrefix( + apiOptions.spaceId + )}/internal/alerting/rules/gaps/auto_fill_scheduler` + ) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password) + .send({ + name: 'gap-scheduler', + rule_types: [ + { type: 'test.patternFiringAutoRecoverFalse', consumer: 'alertsFixture' }, + ], + scope: ['test-scope'], + max_backfills: 1000, + num_retries: 1, + gap_fill_range: 'now-60d', + enabled: true, + schedule: { interval: '1m' }, + }); + + const schedulerId = createSchedulerResp.body.id; + // For unauthorized scenarios, ensure 403 on delete + const resp = await supertestWithoutAuth + .delete( + `${getUrlPrefix( + apiOptions.spaceId + )}/internal/alerting/rules/gaps/auto_fill_scheduler/${schedulerId}` + ) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password); + + expect(resp.statusCode).to.eql(403); + return; + } + + // Create a rule + const ruleResp = await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getRule()) + .expect(200); + const ruleId = ruleResp.body.id; + objectRemover.add(apiOptions.spaceId, ruleId, 'rule', 'alerting'); + + // Report a very long gap (60 days) to generate a large schedule in a system backfill + const gapStart = moment().subtract(60, 'days').startOf('day').toISOString(); + const gapEnd = moment().subtract(1, 'day').startOf('day').toISOString(); + await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/_test/report_gap`) + .set('kbn-xsrf', 'foo') + .send({ + ruleId, + start: gapStart, + end: gapEnd, + spaceId: apiOptions.spaceId, + }) + .expect(200); + + // Create the scheduler + const createSchedulerResp = await supertestWithoutAuth + .post( + `${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/gaps/auto_fill_scheduler` + ) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password) + .send({ + name: `it-delete-scheduler-${Date.now()}`, + schedule: { interval: '1m' }, + gap_fill_range: 'now-60d', + max_backfills: 1000, + num_retries: 1, + scope: ['test-scope'], + rule_types: [ + { type: 'test.patternFiringAutoRecoverFalse', consumer: 'alertsFixture' }, + ], + }); + expect(createSchedulerResp.statusCode).to.eql(200); + const schedulerId = createSchedulerResp.body.id; + expect(typeof schedulerId).to.be('string'); + + // Verify that at least one system backfill exists and has a large schedule + await retry.try(async () => { + const resp = await supertestWithoutAuth + .post(`${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/_find`) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password) + .query({ + rule_ids: ruleId, + page: 1, + per_page: 100, + initiator: 'system', + }); + expect(resp.statusCode).to.eql(200); + const data = resp.body?.data ?? []; + expect(Array.isArray(data)).to.be(true); + expect(data.length > 0).to.be(true); + const schedules = data[0]?.schedule ?? []; + expect(schedules.length >= 10).to.be(true); + }); + + // Verify that in_progress intervals are present + await retry.try(async () => { + await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/_test/event_log/refresh`) + .set('kbn-xsrf', 'foo') + .send({}); + const gapsResp = await findGaps({ + ruleId, + start: gapStart, + end: gapEnd, + spaceId: apiOptions.spaceId, + }); + expect(gapsResp.statusCode).to.eql(200); + expect(gapsResp.body.total).to.eql(1); + const gap = gapsResp.body.data[0]; + expect(gap.in_progress_intervals.length > 0).to.be(true); + }); + + // Delete the scheduler + const deleteResp = await supertestWithoutAuth + .delete( + `${getUrlPrefix( + apiOptions.spaceId + )}/internal/alerting/rules/gaps/auto_fill_scheduler/${schedulerId}` + ) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password); + expect(deleteResp.statusCode).to.eql(204); + + // Verify backfills are removed + await retry.try(async () => { + const resp = await supertestWithoutAuth + .post(`${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/_find`) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password) + .query({ + rule_ids: ruleId, + page: 1, + per_page: 100, + initiator: 'system', + }); + expect(resp.statusCode).to.eql(200); + const total = resp.body?.total ?? 0; + expect(total).to.eql(0); + }); + + // After deletion, gaps should be unfilled again + await retry.try(async () => { + await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/_test/event_log/refresh`) + .set('kbn-xsrf', 'foo') + .send({}); + const gapsResp = await findGaps({ + ruleId, + start: gapStart, + end: gapEnd, + spaceId: apiOptions.spaceId, + }); + expect(gapsResp.statusCode).to.eql(200); + expect(gapsResp.body.total).to.eql(1); + const gap = gapsResp.body.data[0]; + expect(['partially_filled', 'unfilled']).to.contain(gap.status); + + expect(gap.in_progress_intervals.length).to.eql(0); + }); + }); + }); + } + }); +} diff --git a/x-pack/platform/test/alerting_api_integration/security_and_spaces/group8/tests/alerting/gap/get_gap_auto_fill_scheduler.ts b/x-pack/platform/test/alerting_api_integration/security_and_spaces/group8/tests/alerting/gap/get_gap_auto_fill_scheduler.ts new file mode 100644 index 0000000000000..0b402d6072103 --- /dev/null +++ b/x-pack/platform/test/alerting_api_integration/security_and_spaces/group8/tests/alerting/gap/get_gap_auto_fill_scheduler.ts @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { UserAtSpaceScenarios } from '../../../../scenarios'; +import type { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { getUrlPrefix } from '../../../../../common/lib'; + +export default function getGapAutoFillSchedulerTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('gap auto fill scheduler - get by id', () => { + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + describe(scenario.id, () => { + const apiOptions = { + spaceId: space.id, + username: user.username, + password: user.password, + }; + + afterEach(async () => { + await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/_test/gap_auto_fill_scheduler/_delete_all`) + .set('kbn-xsrf', 'foo') + .send({}); + }); + + it('gets scheduler by id with', async () => { + const createBody = { + name: `it-scheduler-get-${Date.now()}`, + schedule: { interval: '1m' }, + gap_fill_range: 'now-30d', + max_backfills: 10, + num_retries: 1, + scope: ['test-scope'], + rule_types: [{ type: 'test.patternFiringAutoRecoverFalse', consumer: 'alertsFixture' }], + }; + const createResp = await supertest + .post( + `${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/gaps/auto_fill_scheduler` + ) + .set('kbn-xsrf', 'foo') + .send(createBody); + expect(createResp.statusCode).to.eql(200); + const schedulerId = createResp.body.id ?? createResp.body?.body?.id; + expect(typeof schedulerId).to.be('string'); + + const getResp = await supertestWithoutAuth + .get( + `${getUrlPrefix( + apiOptions.spaceId + )}/internal/alerting/rules/gaps/auto_fill_scheduler/${schedulerId}` + ) + .auth(apiOptions.username, apiOptions.password); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(getResp.statusCode).to.eql(403); + break; + case 'global_read at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'superuser at space1': + case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': { + expect(getResp.statusCode).to.eql(200); + const body = getResp.body ?? getResp.body?.body; + // Minimal shape/assertions + expect(body.id).to.eql(schedulerId); + expect(typeof body.name).to.be('string'); + expect(body.schedule).to.have.property('interval'); + expect(typeof body.gap_fill_range).to.be('string'); + expect(typeof body.max_backfills).to.be('number'); + expect(typeof body.num_retries).to.be('number'); + break; + } + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('returns 404 for non-existent id when authorized', async () => { + if ( + ![ + 'superuser at space1', + 'space_1_all at space1', + 'space_1_all_alerts_none_actions at space1', + 'space_1_all_with_restricted_fixture at space1', + ].includes(scenario.id) + ) { + return; + } + + const resp = await supertestWithoutAuth + .get( + `${getUrlPrefix( + apiOptions.spaceId + )}/internal/alerting/rules/gaps/auto_fill_scheduler/does-not-exist` + ) + .auth(apiOptions.username, apiOptions.password); + expect(resp.statusCode).to.eql(404); + }); + }); + } + }); +} diff --git a/x-pack/platform/test/alerting_api_integration/security_and_spaces/group8/tests/alerting/gap/get_gap_auto_fill_scheduler_logs.ts b/x-pack/platform/test/alerting_api_integration/security_and_spaces/group8/tests/alerting/gap/get_gap_auto_fill_scheduler_logs.ts new file mode 100644 index 0000000000000..dd995e2820354 --- /dev/null +++ b/x-pack/platform/test/alerting_api_integration/security_and_spaces/group8/tests/alerting/gap/get_gap_auto_fill_scheduler_logs.ts @@ -0,0 +1,242 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import moment from 'moment'; +import { UserAtSpaceScenarios } from '../../../../scenarios'; +import type { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { getUrlPrefix, ObjectRemover } from '../../../../../common/lib'; + +export default function getGapAutoFillSchedulerLogsTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('gap auto fill scheduler - get logs', () => { + const objectRemover = new ObjectRemover(supertest); + + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + describe(scenario.id, () => { + const apiOptions = { + spaceId: space.id, + username: user.username, + password: user.password, + }; + + afterEach(async () => { + await objectRemover.removeAll(); + await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/_test/gap_auto_fill_scheduler/_delete_all`) + .set('kbn-xsrf', 'foo') + .send({}); + }); + + it('gets scheduler logs by id', async () => { + const createBody = { + name: `it-scheduler-logs-${Date.now()}`, + schedule: { interval: '1m' }, + gap_fill_range: 'now-30d', + max_backfills: 10, + num_retries: 1, + scope: ['test-scope'], + rule_types: [{ type: 'test.patternFiringAutoRecoverFalse', consumer: 'alertsFixture' }], + }; + const createResp = await supertest + .post( + `${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/gaps/auto_fill_scheduler` + ) + .set('kbn-xsrf', 'foo') + .send(createBody); + expect(createResp.statusCode).to.eql(200); + const schedulerId = createResp.body.id ?? createResp.body?.body?.id; + expect(typeof schedulerId).to.be('string'); + + const start = moment().subtract(7, 'days').toISOString(); + const end = moment().toISOString(); + + const getLogsResp = await supertestWithoutAuth + .post( + `${getUrlPrefix( + apiOptions.spaceId + )}/internal/alerting/rules/gaps/auto_fill_scheduler/${schedulerId}/logs` + ) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password) + .send({ + start, + end, + page: 1, + per_page: 50, + sort_field: '@timestamp', + sort_direction: 'desc', + }); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(getLogsResp.statusCode).to.eql(403); + break; + case 'global_read at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'superuser at space1': + case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': { + expect(getLogsResp.statusCode).to.eql(200); + const body = getLogsResp.body ?? getLogsResp.body?.body; + // Minimal shape/assertions + expect(body).to.have.property('data'); + expect(Array.isArray(body.data)).to.be(true); + expect(body).to.have.property('total'); + expect(typeof body.total).to.be('number'); + expect(body).to.have.property('page'); + expect(typeof body.page).to.be('number'); + expect(body).to.have.property('per_page'); + expect(typeof body.per_page).to.be('number'); + + // Test 404 for non-existent id when authorized + const notFoundResp = await supertestWithoutAuth + .get( + `${getUrlPrefix( + apiOptions.spaceId + )}/internal/alerting/rules/gaps/auto_fill_scheduler/does-not-exist/logs` + ) + .query({ + start, + end, + page: 1, + per_page: 50, + sort_field: '@timestamp', + sort_direction: 'desc', + }) + .auth(apiOptions.username, apiOptions.password); + expect(notFoundResp.statusCode).to.eql(404); + break; + } + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('gets scheduler logs with status filter', async () => { + if ( + ![ + 'superuser at space1', + 'space_1_all at space1', + 'space_1_all_alerts_none_actions at space1', + 'space_1_all_with_restricted_fixture at space1', + ].includes(scenario.id) + ) { + return; + } + + const createBody = { + name: `it-scheduler-logs-status-${Date.now()}`, + schedule: { interval: '1m' }, + gap_fill_range: 'now-30d', + max_backfills: 10, + num_retries: 1, + scope: ['test-scope'], + rule_types: [{ type: 'test.patternFiringAutoRecoverFalse', consumer: 'alertsFixture' }], + }; + const createResp = await supertest + .post( + `${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/gaps/auto_fill_scheduler` + ) + .set('kbn-xsrf', 'foo') + .send(createBody); + expect(createResp.statusCode).to.eql(200); + const schedulerId = createResp.body.id ?? createResp.body?.body?.id; + + const start = moment().subtract(7, 'days').toISOString(); + const end = moment().toISOString(); + + const getLogsResp = await supertestWithoutAuth + .post( + `${getUrlPrefix( + apiOptions.spaceId + )}/internal/alerting/rules/gaps/auto_fill_scheduler/${schedulerId}/logs` + ) + .auth(apiOptions.username, apiOptions.password) + .set('kbn-xsrf', 'foo') + .send({ + start, + end, + page: 1, + per_page: 50, + sort_field: '@timestamp', + sort_direction: 'desc', + statuses: ['success', 'error'], + }); + + expect(getLogsResp.statusCode).to.eql(200); + const body = getLogsResp.body ?? getLogsResp.body?.body; + expect(body).to.have.property('data'); + expect(Array.isArray(body.data)).to.be(true); + }); + + it('gets scheduler logs with pagination', async () => { + if ( + ![ + 'superuser at space1', + 'space_1_all at space1', + 'space_1_all_alerts_none_actions at space1', + 'space_1_all_with_restricted_fixture at space1', + ].includes(scenario.id) + ) { + return; + } + + const createBody = { + name: `it-scheduler-logs-pagination-${Date.now()}`, + schedule: { interval: '1m' }, + gap_fill_range: 'now-30d', + max_backfills: 10, + num_retries: 1, + scope: ['test-scope'], + rule_types: [{ type: 'test.patternFiringAutoRecoverFalse', consumer: 'alertsFixture' }], + }; + const createResp = await supertest + .post( + `${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/gaps/auto_fill_scheduler` + ) + .set('kbn-xsrf', 'foo') + .send(createBody); + expect(createResp.statusCode).to.eql(200); + const schedulerId = createResp.body.id ?? createResp.body?.body?.id; + + const start = moment().subtract(7, 'days').toISOString(); + const end = moment().toISOString(); + + const getLogsResp = await supertestWithoutAuth + .post( + `${getUrlPrefix( + apiOptions.spaceId + )}/internal/alerting/rules/gaps/auto_fill_scheduler/${schedulerId}/logs` + ) + .auth(apiOptions.username, apiOptions.password) + .set('kbn-xsrf', 'foo') + .send({ + start, + end, + page: 1, + per_page: 10, + sort_field: '@timestamp', + sort_direction: 'asc', + }); + + expect(getLogsResp.statusCode).to.eql(200); + const body = getLogsResp.body ?? getLogsResp.body?.body; + expect(body).to.have.property('data'); + expect(body).to.have.property('page'); + expect(body.page).to.eql(1); + expect(body).to.have.property('per_page'); + expect(body.per_page).to.eql(10); + }); + }); + } + }); +} diff --git a/x-pack/platform/test/alerting_api_integration/security_and_spaces/group8/tests/alerting/gap/index.ts b/x-pack/platform/test/alerting_api_integration/security_and_spaces/group8/tests/alerting/gap/index.ts index e2ecd149d5fa6..d75484a8f9d2e 100644 --- a/x-pack/platform/test/alerting_api_integration/security_and_spaces/group8/tests/alerting/gap/index.ts +++ b/x-pack/platform/test/alerting_api_integration/security_and_spaces/group8/tests/alerting/gap/index.ts @@ -15,5 +15,9 @@ export default function gapsTests({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./get_gaps_summary_by_rule_ids')); loadTestFile(require.resolve('./fill_gap_by_id')); loadTestFile(require.resolve('./create_gap_auto_fill_scheduler')); + loadTestFile(require.resolve('./get_gap_auto_fill_scheduler')); + loadTestFile(require.resolve('./get_gap_auto_fill_scheduler_logs')); + loadTestFile(require.resolve('./update_gap_auto_fill_scheduler')); + loadTestFile(require.resolve('./delete_gap_auto_fill_scheduler')); }); } diff --git a/x-pack/platform/test/alerting_api_integration/security_and_spaces/group8/tests/alerting/gap/update_gap_auto_fill_scheduler.ts b/x-pack/platform/test/alerting_api_integration/security_and_spaces/group8/tests/alerting/gap/update_gap_auto_fill_scheduler.ts new file mode 100644 index 0000000000000..3e125ef022d12 --- /dev/null +++ b/x-pack/platform/test/alerting_api_integration/security_and_spaces/group8/tests/alerting/gap/update_gap_auto_fill_scheduler.ts @@ -0,0 +1,273 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import moment from 'moment'; +import { UserAtSpaceScenarios } from '../../../../scenarios'; +import type { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { getUrlPrefix, ObjectRemover, getTestRuleData } from '../../../../../common/lib'; +import { getFindGaps } from './utils'; + +export default function updateGapAutoFillSchedulerTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const logger = getService('log'); + const retry = getService('retry'); + const findGaps = getFindGaps({ supertest, logger }); + + describe('gap auto fill scheduler - update', () => { + const objectRemover = new ObjectRemover(supertest); + const gapStart = moment().subtract(30, 'days').startOf('day').toISOString(); + const gapEnd = moment().subtract(1, 'day').startOf('day').toISOString(); + + function getRule(overwrites: Record = {}) { + return getTestRuleData({ + rule_type_id: 'test.patternFiringAutoRecoverFalse', + params: { + pattern: { + instance: [true, false, true], + }, + }, + schedule: { interval: '6h' }, + ...overwrites, + }); + } + + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + describe(scenario.id, () => { + const apiOptions = { + spaceId: space.id, + username: user.username, + password: user.password, + }; + + afterEach(async () => { + await objectRemover.removeAll(); + await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/_test/gap_auto_fill_scheduler/_delete_all`) + .set('kbn-xsrf', 'foo') + .send({}); + }); + + it('updates scheduler attributes when authorized', async () => { + const url = `${getUrlPrefix( + apiOptions.spaceId + )}/internal/alerting/rules/gaps/auto_fill_scheduler`; + + const schedulerBody = { + name: `update-scheduler-${Date.now()}`, + schedule: { interval: '1m' }, + gap_fill_range: 'now-90d', + max_backfills: 100, + num_retries: 1, + scope: ['test-scope'], + rule_types: [{ type: 'test.patternFiringAutoRecoverFalse', consumer: 'alertsFixture' }], + }; + + const createResp = await supertestWithoutAuth + .post(url) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password) + .send(schedulerBody); + + if ( + ![ + 'superuser at space1', + 'space_1_all at space1', + 'space_1_all_alerts_none_actions at space1', + 'space_1_all_with_restricted_fixture at space1', + ].includes(scenario.id) + ) { + expect(createResp.statusCode).to.eql(403); + return; + } + + expect(createResp.statusCode).to.eql(200); + const schedulerId = createResp.body.id ?? createResp.body?.body?.id; + expect(typeof schedulerId).to.be('string'); + + const updateResp = await supertestWithoutAuth + .put(`${url}/${schedulerId}`) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password) + .send({ + name: `${schedulerBody.name}-updated`, + enabled: true, + schedule: { interval: '5m' }, + gap_fill_range: 'now-45d', + max_backfills: 250, + num_retries: 3, + scope: ['test-scope'], + rule_types: schedulerBody.rule_types, + }); + + expect(updateResp.statusCode).to.eql(200); + expect(updateResp.body.name ?? updateResp.body?.body?.name).to.eql( + `${schedulerBody.name}-updated` + ); + }); + + it('disables scheduler and removes system backfills synchronously', async () => { + if ( + ![ + 'superuser at space1', + 'space_1_all at space1', + 'space_1_all_alerts_none_actions at space1', + 'space_1_all_with_restricted_fixture at space1', + ].includes(scenario.id) + ) { + // Create scheduler as authorized user in order to ensure 403 for others + const createResp = await supertestWithoutAuth + .post( + `${getUrlPrefix( + apiOptions.spaceId + )}/internal/alerting/rules/gaps/auto_fill_scheduler` + ) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password) + .send({ + name: 'should-fail', + schedule: { interval: '1h' }, + gap_fill_range: 'now-60d', + max_backfills: 100, + num_retries: 1, + scope: ['test-scope'], + rule_types: [ + { type: 'test.patternFiringAutoRecoverFalse', consumer: 'alertsFixture' }, + ], + }); + expect(createResp.statusCode).to.eql(403); + return; + } + + // Create a rule + const ruleResp = await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getRule()) + .expect(200); + const ruleId = ruleResp.body.id; + objectRemover.add(apiOptions.spaceId, ruleId, 'rule', 'alerting'); + + // Report a gap + await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/_test/report_gap`) + .set('kbn-xsrf', 'foo') + .send({ + ruleId, + start: gapStart, + end: gapEnd, + spaceId: apiOptions.spaceId, + }) + .expect(200); + + // Create the scheduler + const schedulerBody = { + name: `disable-update-${Date.now()}`, + schedule: { interval: '1m' }, + gap_fill_range: 'now-60d', + max_backfills: 1000, + num_retries: 1, + scope: ['test-scope'], + rule_types: [{ type: 'test.patternFiringAutoRecoverFalse', consumer: 'alertsFixture' }], + }; + + const createResp = await supertestWithoutAuth + .post( + `${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/gaps/auto_fill_scheduler` + ) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password) + .send(schedulerBody) + .expect(200); + + const schedulerId = createResp.body.id ?? createResp.body?.body?.id; + expect(typeof schedulerId).to.be('string'); + + // Wait for a system backfill to appear + let capturedBackfillIds: string[] = []; + await retry.try(async () => { + const resp = await supertestWithoutAuth + .post(`${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/_find`) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password) + .query({ + rule_ids: ruleId, + page: 1, + per_page: 100, + initiator: 'system', + }); + expect(resp.statusCode).to.eql(200); + const data = resp.body?.data ?? []; + expect(Array.isArray(data)).to.be(true); + expect(data.length > 0).to.be(true); + capturedBackfillIds = data.map((d: any) => d.id).filter(Boolean); + }); + expect(capturedBackfillIds.length > 0).to.be(true); + + // Put to disable the scheduler + const putResp = await supertestWithoutAuth + .put( + `${getUrlPrefix( + apiOptions.spaceId + )}/internal/alerting/rules/gaps/auto_fill_scheduler/${schedulerId}` + ) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password) + .send({ + enabled: false, + name: schedulerBody.name, + schedule: schedulerBody.schedule, + gap_fill_range: schedulerBody.gap_fill_range, + max_backfills: schedulerBody.max_backfills, + num_retries: schedulerBody.num_retries, + rule_types: schedulerBody.rule_types, + scope: schedulerBody.scope, + }) + .expect(200); + + expect(putResp.body.enabled ?? putResp.body?.body?.enabled).to.be(false); + + // Ensure system backfills are removed + await retry.try(async () => { + const resp = await supertestWithoutAuth + .post(`${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/_find`) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password) + .query({ + rule_ids: ruleId, + page: 1, + per_page: 100, + initiator: 'system', + }); + expect(resp.statusCode).to.eql(200); + const data = resp.body?.data ?? []; + expect(data.length).to.eql(0); + }); + + // Verify gaps no longer have in-progress intervals + await retry.try(async () => { + await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/_test/event_log/refresh`) + .set('kbn-xsrf', 'foo') + .send({}); + const gapsResp = await findGaps({ + ruleId, + start: gapStart, + end: gapEnd, + spaceId: apiOptions.spaceId, + }); + expect(gapsResp.statusCode).to.eql(200); + const gap = gapsResp.body.data[0]; + expect(gap?.in_progress_intervals?.length ?? 0).to.eql(0); + }); + }); + }); + } + }); +} diff --git a/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts b/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts index 26541bbabe73b..a9b15e4990b18 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts @@ -203,6 +203,11 @@ export const allowedExperimentalValues = Object.freeze({ * Enables the Entity Analytics Watchlist feature. */ entityAnalyticsWatchlistEnabled: false, + + /** + * Enables the Gap Auto Fill Scheduler feature. + */ + gapAutoFillSchedulerEnabled: false, }); type ExperimentalConfigKeys = Array; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/common/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/common/translations.ts index 1c6105e9016cd..6d3e197559829 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/common/translations.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/common/translations.ts @@ -1628,3 +1628,10 @@ export const COLUMN_TOTAL_UNFILLED_GAPS_DURATION_TOOLTIP = i18n.translate( defaultMessage: 'Sum of remaining unfilled or partially filled gaps', } ); + +export const RULE_SETTINGS_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleManagement.ruleSettingsTitle', + { + defaultMessage: 'Settings', + } +); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/api/api.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/api/api.ts index ba15ed5d1ff71..3e53118f42d6d 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/api/api.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/api/api.ts @@ -13,6 +13,7 @@ import { INTERNAL_ALERTING_GAPS_GET_SUMMARY_BY_RULE_IDS_API_PATH, INTERNAL_ALERTING_GAPS_FILL_BY_ID_API_PATH, INTERNAL_ALERTING_GAPS_FIND_API_PATH, + INTERNAL_ALERTING_GAPS_AUTO_FILL_SCHEDULER_API_PATH, gapStatus, } from '@kbn/alerting-plugin/common'; import type { FindBackfillResponseBody } from '@kbn/alerting-plugin/common/routes/backfill/apis/find'; @@ -21,9 +22,13 @@ import type { GetGapsSummaryByRuleIdsResponseBody } from '@kbn/alerting-plugin/c import type { GetRuleIdsWithGapResponseBody } from '@kbn/alerting-plugin/common/routes/gaps/apis/get_rules_with_gaps'; import type { FindGapsResponseBody } from '@kbn/alerting-plugin/common/routes/gaps/apis/find'; import type { FillGapByIdResponseV1 } from '@kbn/alerting-plugin/common/routes/gaps/apis/fill'; +import type { + GapAutoFillSchedulerResponseBodyV1, + GapAutoFillSchedulerLogsResponseBodyV1, +} from '@kbn/alerting-plugin/common/routes/gaps/apis/gap_auto_fill_scheduler'; import dateMath from '@kbn/datemath'; import { KibanaServices } from '../../../common/lib/kibana'; -import type { GapStatus, ScheduleBackfillProps } from '../types'; +import type { GapStatus, ScheduleBackfillProps, GapAutoFillSchedulerBase } from '../types'; /** * Schedule rules run over a specified time range @@ -71,24 +76,27 @@ export const findBackfillsForRules = async ({ signal, sortField = 'createdAt', sortOrder = 'desc', + initiator, }: { - ruleIds: string[]; + ruleIds?: string[]; page: number; perPage: number; signal?: AbortSignal; sortField?: string; sortOrder?: string; + initiator?: string; }): Promise => KibanaServices.get().http.fetch( INTERNAL_ALERTING_BACKFILL_FIND_API_PATH, { method: 'POST', query: { - rule_ids: ruleIds.join(','), + ...(ruleIds && ruleIds.length > 0 ? { rule_ids: ruleIds.join(',') } : {}), page, per_page: perPage, sort_field: sortField, sort_order: sortOrder, + initiator, }, signal, } @@ -258,3 +266,97 @@ export const fillGapByIdForRule = async ({ signal, } ); + +export const getGapAutoFillScheduler = async ({ + id, + signal, +}: { + id: string; + signal?: AbortSignal; +}): Promise => + KibanaServices.get().http.fetch( + `${INTERNAL_ALERTING_GAPS_AUTO_FILL_SCHEDULER_API_PATH}/${id}`, + { + method: 'GET', + signal, + } + ); + +export const createGapAutoFillScheduler = async ( + params: GapAutoFillSchedulerBase +): Promise => + KibanaServices.get().http.fetch( + INTERNAL_ALERTING_GAPS_AUTO_FILL_SCHEDULER_API_PATH, + { + method: 'POST', + body: JSON.stringify({ + id: params.id, + name: params.name, + scope: params.scope, + schedule: params.schedule, + rule_types: params.ruleTypes, + gap_fill_range: params.gapFillRange, + max_backfills: params.maxBackfills, + num_retries: params.numRetries, + enabled: params.enabled, + }), + } + ); + +export const updateGapAutoFillScheduler = async ( + params: GapAutoFillSchedulerBase +): Promise => + KibanaServices.get().http.fetch( + `${INTERNAL_ALERTING_GAPS_AUTO_FILL_SCHEDULER_API_PATH}/${params.id}`, + { + method: 'PUT', + body: JSON.stringify({ + name: params.name, + scope: params.scope, + schedule: params.schedule, + rule_types: params.ruleTypes, + gap_fill_range: params.gapFillRange, + max_backfills: params.maxBackfills, + num_retries: params.numRetries, + enabled: params.enabled, + }), + } + ); + +export const getGapAutoFillSchedulerLogs = async ({ + id, + start, + end, + page, + perPage, + sortField, + sortDirection, + statuses, + signal, +}: { + id: string; + start: string; + end: string; + page: number; + perPage: number; + sortField: string; + sortDirection: string; + statuses: string[]; + signal?: AbortSignal; +}): Promise => + KibanaServices.get().http.fetch( + `${INTERNAL_ALERTING_GAPS_AUTO_FILL_SCHEDULER_API_PATH}/${id}/logs`, + { + method: 'POST', + signal, + body: JSON.stringify({ + start, + end, + page, + per_page: perPage, + sort_field: sortField, + sort_direction: sortDirection, + ...(statuses && statuses.length > 0 && { statuses: [...statuses] }), + }), + } + ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/api/hooks/use_gap_auto_fill_scheduler.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/api/hooks/use_gap_auto_fill_scheduler.ts new file mode 100644 index 0000000000000..7de071cd8eb5b --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/api/hooks/use_gap_auto_fill_scheduler.ts @@ -0,0 +1,184 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { useQuery, useMutation, useQueryClient } from '@kbn/react-query'; +import type { GapAutoFillSchedulerResponseBodyV1 } from '@kbn/alerting-plugin/common/routes/gaps/apis/gap_auto_fill_scheduler'; +import { SECURITY_SOLUTION_RULE_TYPE_IDS } from '@kbn/securitysolution-rules'; +import { SERVER_APP_ID } from '@kbn/security-solution-features/constants'; +import { useSpaceId } from '../../../../common/hooks/use_space_id'; +import { + createGapAutoFillScheduler, + getGapAutoFillScheduler, + getGapAutoFillSchedulerLogs, + updateGapAutoFillScheduler, +} from '../api'; +import type { GapAutoFillSchedulerResponse, GapAutoFillSchedulerBase } from '../../types'; +import { + DEFAULT_GAP_AUTO_FILL_SCHEDULER_GAP_FILL_RANGE, + DEFAULT_GAP_AUTO_FILL_SCHEDULER_INTERVAL, + DEFAULT_GAP_AUTO_FILL_SCHEDULER_MAX_BACKFILLS, + DEFAULT_GAP_AUTO_FILL_SCHEDULER_NUM_RETRIES, + DEFAULT_GAP_AUTO_FILL_SCHEDULER_SCOPE, + DEFAULT_GAP_AUTO_FILL_SCHEDULER_ID_PREFIX, + defaultRangeValue, +} from '../../constants'; +import { getGapRange } from './utils'; + +const transformGapAutoFillSchedulerResponseBody = ( + response: GapAutoFillSchedulerResponseBodyV1 +): GapAutoFillSchedulerResponse => { + return { + id: response.id, + name: response.name, + enabled: response.enabled, + gapFillRange: response.gap_fill_range, + maxBackfills: response.max_backfills, + numRetries: response.num_retries, + schedule: { + interval: response.schedule.interval, + }, + scope: response.scope, + ruleTypes: response.rule_types, + createdBy: response.created_by, + updatedBy: response.updated_by, + createdAt: response.created_at, + updatedAt: response.updated_at, + }; +}; + +const getSchedulerId = (spaceId?: string) => + spaceId ? `${DEFAULT_GAP_AUTO_FILL_SCHEDULER_ID_PREFIX}-${spaceId}` : 'default'; + +export const useGetGapAutoFillScheduler = (options?: { enabled?: boolean }) => { + const enabled = options?.enabled ?? true; + const spaceId = useSpaceId(); + const schedulerId = getSchedulerId(spaceId); + + return useQuery( + ['GET', 'gap_auto_fill_scheduler', schedulerId], + async ({ signal }) => { + const response = await getGapAutoFillScheduler({ id: schedulerId, signal }); + return transformGapAutoFillSchedulerResponseBody(response); + }, + { enabled, refetchOnWindowFocus: false, retry: false } + ); +}; + +export const useCreateGapAutoFillScheduler = () => { + const queryClient = useQueryClient(); + const spaceId = useSpaceId(); + const schedulerId = getSchedulerId(spaceId); + + return useMutation( + async () => { + const fullBody = { + id: schedulerId, + name: '', + enabled: true, + gapFillRange: DEFAULT_GAP_AUTO_FILL_SCHEDULER_GAP_FILL_RANGE, + ruleTypes: SECURITY_SOLUTION_RULE_TYPE_IDS.map((typeId) => ({ + type: typeId, + consumer: SERVER_APP_ID, + })), + schedule: { + interval: DEFAULT_GAP_AUTO_FILL_SCHEDULER_INTERVAL, + }, + maxBackfills: DEFAULT_GAP_AUTO_FILL_SCHEDULER_MAX_BACKFILLS, + numRetries: DEFAULT_GAP_AUTO_FILL_SCHEDULER_NUM_RETRIES, + scope: DEFAULT_GAP_AUTO_FILL_SCHEDULER_SCOPE, + }; + const response = await createGapAutoFillScheduler(fullBody); + return transformGapAutoFillSchedulerResponseBody(response); + }, + { + mutationKey: ['POST', 'gap_auto_fill_scheduler'], + onSettled: () => { + queryClient.invalidateQueries(['GET', 'gap_auto_fill_scheduler', schedulerId]); + }, + } + ); +}; + +export const useUpdateGapAutoFillScheduler = () => { + const queryClient = useQueryClient(); + const spaceId = useSpaceId(); + const schedulerId = getSchedulerId(spaceId); + + return useMutation((body: GapAutoFillSchedulerBase) => updateGapAutoFillScheduler(body), { + mutationKey: ['PUT', 'gap_auto_fill_scheduler', schedulerId], + onSettled: () => { + queryClient.invalidateQueries(['GET', 'gap_auto_fill_scheduler', schedulerId]); + }, + }); +}; + +export const useGetGapAutoFillSchedulerLogs = ({ + page, + perPage, + sortField, + sortDirection, + statuses, + enabled, +}: { + page: number; + perPage: number; + sortField: string; + sortDirection: string; + statuses: string[]; + enabled: boolean; +}) => { + const spaceId = useSpaceId(); + const schedulerId = getSchedulerId(spaceId); + + const { start, end } = getGapRange(defaultRangeValue); + + return useQuery( + [ + 'GET', + 'gap_auto_fill_scheduler_logs', + schedulerId, + page, + perPage, + sortField, + sortDirection, + ...statuses, + ], + async ({ signal }) => { + const response = await getGapAutoFillSchedulerLogs({ + id: schedulerId, + signal, + start, + end, + page, + perPage, + sortField, + sortDirection, + statuses, + }); + + return { + data: response?.data?.map((log) => ({ + id: log.id, + timestamp: log.timestamp, + status: log.status, + message: log.message, + results: log.results?.map((result) => ({ + ruleId: result.rule_id, + processedGaps: result.processed_gaps, + status: result.status, + error: result.error, + })), + })), + total: response.total, + page: response.page, + perPage: response.per_page, + }; + }, + { + enabled, + } + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/components/gap_auto_fill_logs/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/components/gap_auto_fill_logs/index.tsx new file mode 100644 index 0000000000000..531ca419286e3 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/components/gap_auto_fill_logs/index.tsx @@ -0,0 +1,284 @@ +/* + * 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, { useMemo, useState } from 'react'; +import type { EuiBasicTableColumn } from '@elastic/eui'; +import { + EuiBadge, + EuiButtonEmpty, + EuiBasicTable, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiPanel, + EuiSpacer, + EuiText, + EuiTitle, + EuiFlexGroup, + EuiFlexItem, + EuiFilterGroup, + EuiButtonIcon, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import type { GapAutoFillSchedulerLogsResponseBodyV1 } from '@kbn/alerting-plugin/common/routes/gaps/apis/gap_auto_fill_scheduler'; +import { GAP_AUTO_FILL_STATUS, type GapAutoFillStatus } from '@kbn/alerting-plugin/common'; +import { CallOutSwitcher } from '../../../../common/components/callouts'; +import { FormattedDate } from '../../../../common/components/formatted_date'; +import * as i18n from './translations'; +import { + useGetGapAutoFillScheduler, + useGetGapAutoFillSchedulerLogs, +} from '../../api/hooks/use_gap_auto_fill_scheduler'; +import { MultiselectFilter } from '../../../../common/components/multiselect_filter'; + +interface GapAutoFillLogsFlyoutProps { + isOpen: boolean; + onClose: () => void; +} + +const statuses: GapAutoFillStatus[] = [ + GAP_AUTO_FILL_STATUS.SUCCESS, + GAP_AUTO_FILL_STATUS.ERROR, + GAP_AUTO_FILL_STATUS.SKIPPED, + GAP_AUTO_FILL_STATUS.NO_GAPS, +]; + +export const GapAutoFillLogsFlyout = ({ isOpen, onClose }: GapAutoFillLogsFlyoutProps) => { + const { data: scheduler } = useGetGapAutoFillScheduler({ enabled: isOpen }); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + const [selectedStatuses, setSelectedStatuses] = useState([ + GAP_AUTO_FILL_STATUS.SUCCESS, + GAP_AUTO_FILL_STATUS.ERROR, + ]); + + const { data: logsData, isFetching: isLogsLoading } = useGetGapAutoFillSchedulerLogs({ + page: pageIndex + 1, + perPage: pageSize, + statuses: selectedStatuses, + sortField: '@timestamp', + sortDirection: 'desc', + enabled: isOpen, + }); + + type SchedulerLog = GapAutoFillSchedulerLogsResponseBodyV1['data'][number]; + const [expandedRowMap, setExpandedRowMap] = useState>({}); + + const enabled = scheduler?.enabled; + const color = enabled ? 'success' : 'hollow'; + const iconType = enabled ? 'checkInCircleFilled' : 'minusInCircle'; + + const getStatusLabel = (status: string | undefined) => { + if (!status) return ''; + switch (status) { + case GAP_AUTO_FILL_STATUS.NO_GAPS: + return i18n.GAP_AUTO_FILL_STATUS_NO_GAPS; + case GAP_AUTO_FILL_STATUS.SUCCESS: + return i18n.GAP_AUTO_FILL_STATUS_SUCCESS; + case GAP_AUTO_FILL_STATUS.ERROR: + return i18n.GAP_AUTO_FILL_STATUS_ERROR; + case GAP_AUTO_FILL_STATUS.SKIPPED: + return i18n.GAP_AUTO_FILL_STATUS_SKIPPED; + default: + return status; + } + }; + + const columns = useMemo( + () => [ + { + field: 'timestamp', + width: '100%', + name: i18n.GAP_AUTO_FILL_RUN_TIME_COLUMN, + render: (timestamp: SchedulerLog['timestamp']) => { + return ; + }, + }, + { + field: 'status', + align: 'center', + width: '150px', + name: i18n.GAP_AUTO_FILL_LOGS_STATUS_COLUMN, + render: (status: SchedulerLog['status']) => { + let badgeColor: 'success' | 'hollow' | 'danger'; + + switch (status) { + case GAP_AUTO_FILL_STATUS.SUCCESS: + badgeColor = 'success'; + break; + case GAP_AUTO_FILL_STATUS.ERROR: + badgeColor = 'danger'; + break; + case GAP_AUTO_FILL_STATUS.SKIPPED: + case GAP_AUTO_FILL_STATUS.NO_GAPS: + default: + badgeColor = 'hollow'; + } + + return {getStatusLabel(status)}; + }, + }, + { + width: '120px', + align: 'right', + isExpander: true, + render: (item: SchedulerLog) => { + const itemIdToExpandedRowMapValues = { ...expandedRowMap }; + const isExpanded = itemIdToExpandedRowMapValues[item.id]; + + const toggleViewLogs = () => { + if (isExpanded) { + delete itemIdToExpandedRowMapValues[item.id]; + } else { + itemIdToExpandedRowMapValues[item.id] = ( + <> + +
{item.message}
+
+ + ); + } + setExpandedRowMap(itemIdToExpandedRowMapValues); + }; + + return ( + + + {i18n.GAP_AUTO_FILL_LOGS_VIEW_LOGS_BUTTON} + + + + ); + }, + }, + ], + [expandedRowMap] + ); + + return ( + <> + {isOpen && ( + + + +

{i18n.GAP_AUTO_FILL_LOGS_TITLE}

+
+
+ + + + + + ), + }} + /> + + + + + + + {i18n.GAP_AUTO_FILL_STATUS_PANEL_TITLE} + + + + {enabled ? i18n.GAP_AUTO_FILL_ON_LABEL : i18n.GAP_AUTO_FILL_OFF_LABEL} + + + + + + + + {i18n.GAP_AUTO_FILL_SCHEDULE_PANEL_TITLE} + + + + + + + + + + + + + +

{i18n.GAP_AUTO_FILL_LOGS_TITLE}

+
+
+ + + setSelectedStatuses(items)} + renderItem={(s: string) => getStatusLabel(s)} + width={200} + /> + + +
+ + + + []} + pagination={{ + pageIndex, + pageSize, + totalItemCount: logsData?.total ?? 0, + pageSizeOptions: [10, 25, 50], + }} + onChange={({ page }: { page?: { index: number; size: number } }) => { + if (page) { + setPageIndex(page.index); + setPageSize(page.size); + } + }} + itemIdToExpandedRowMap={expandedRowMap} + data-test-subj="gap-auto-fill-logs-table" + /> +
+
+
+ )} + + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/components/gap_auto_fill_logs/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/components/gap_auto_fill_logs/translations.ts new file mode 100644 index 0000000000000..482ec7c298b34 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/components/gap_auto_fill_logs/translations.ts @@ -0,0 +1,141 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const GAP_AUTO_FILL_LOGS_TITLE = i18n.translate( + 'xpack.securitySolution.gapAutoFillLogs.title', + { + defaultMessage: 'Gap fill scheduler logs', + } +); + +export const GAP_AUTO_FILL_RUN_TIME_COLUMN = i18n.translate( + 'xpack.securitySolution.gapAutoFillLogs.runTimeColumn', + { + defaultMessage: 'Time', + } +); + +export const GAP_AUTO_FILL_GAPS_SCHEDULED_COLUMN = i18n.translate( + 'xpack.securitySolution.gapAutoFillLogs.gapsScheduledColumn', + { + defaultMessage: 'Gaps scheduled to be filled', + } +); + +export const GAP_AUTO_FILL_STATUS_PANEL_TITLE = i18n.translate( + 'xpack.securitySolution.gapAutoFillLogs.statusPanelTitle', + { + defaultMessage: 'Gap scheduler status', + } +); + +export const GAP_AUTO_FILL_RULES_PROCESSED_COLUMN = i18n.translate( + 'xpack.securitySolution.gapAutoFillLogs.rulesProcessedColumn', + { + defaultMessage: 'Rules processed', + } +); + +export const GAP_AUTO_FILL_ON_LABEL = i18n.translate( + 'xpack.securitySolution.gapAutoFillLogs.autoFillOnLabel', + { + defaultMessage: 'Auto fill on', + } +); + +export const GAP_AUTO_FILL_OFF_LABEL = i18n.translate( + 'xpack.securitySolution.gapAutoFillLogs.autoFillOffLabel', + { + defaultMessage: 'Auto fill off', + } +); + +export const GAP_AUTO_FILL_EXPAND_ARIA_LABEL = i18n.translate( + 'xpack.securitySolution.gapAutoFillLogs.expandRowAriaLabel', + { + defaultMessage: 'Expand row', + } +); + +export const GAP_AUTO_FILL_COLLAPSE_ARIA_LABEL = i18n.translate( + 'xpack.securitySolution.gapAutoFillLogs.collapseRowAriaLabel', + { + defaultMessage: 'Collapse row', + } +); + +export const GAP_AUTO_FILL_REFRESH_LABEL = i18n.translate( + 'xpack.securitySolution.gapAutoFillLogs.refreshLabel', + { + defaultMessage: 'Refresh', + } +); + +export const GAP_AUTO_FILL_STATUS_FILTER_TITLE = i18n.translate( + 'xpack.securitySolution.gapAutoFillLogs.statusFilterTitle', + { + defaultMessage: 'Schedule status', + } +); + +export const GAP_AUTO_FILL_LOGS_STATUS_COLUMN = i18n.translate( + 'xpack.securitySolution.gapAutoFillLogs.logsStatusColumn', + { + defaultMessage: 'Schedule status', + } +); + +export const GAP_AUTO_FILL_LOGS_VIEW_LOGS_BUTTON = i18n.translate( + 'xpack.securitySolution.gapAutoFillLogs.viewLogsButton', + { + defaultMessage: 'View logs', + } +); + +export const GAP_AUTO_FILL_SCHEDULE_PANEL_TITLE = i18n.translate( + 'xpack.securitySolution.gapAutoFillLogs.schedulePanelTitle', + { + defaultMessage: 'Gap scheduler schedule', + } +); + +export const GAP_AUTO_FILL_STATUS_SUCCESS = i18n.translate( + 'xpack.securitySolution.gapAutoFillLogs.statusSuccess', + { + defaultMessage: 'Success', + } +); + +export const GAP_AUTO_FILL_STATUS_ERROR = i18n.translate( + 'xpack.securitySolution.gapAutoFillLogs.statusError', + { + defaultMessage: 'Error', + } +); + +export const GAP_AUTO_FILL_STATUS_SKIPPED = i18n.translate( + 'xpack.securitySolution.gapAutoFillLogs.statusSkipped', + { + defaultMessage: 'Task skipped', + } +); + +export const GAP_AUTO_FILL_LOGS_CALLOUT_TITLE = i18n.translate( + 'xpack.securitySolution.gapAutoFillLogs.calloutTitle', + { + defaultMessage: 'About the scheduler logs', + } +); + +export const GAP_AUTO_FILL_STATUS_NO_GAPS = i18n.translate( + 'xpack.securitySolution.gapAutoFillLogs.statusNoGaps', + { + defaultMessage: 'No gaps', + } +); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/components/rule_settings_modal/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/components/rule_settings_modal/index.tsx new file mode 100644 index 0000000000000..d17c7cad3fd6b --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/components/rule_settings_modal/index.tsx @@ -0,0 +1,160 @@ +/* + * 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, { useState, useEffect } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiFormRow, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiSpacer, + EuiSwitch, + EuiText, + EuiTitle, + EuiHorizontalRule, + EuiLink, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import { + useGetGapAutoFillScheduler, + useCreateGapAutoFillScheduler, + useUpdateGapAutoFillScheduler, +} from '../../api/hooks/use_gap_auto_fill_scheduler'; +import * as i18n from '../../translations'; +import { useGapAutoFillCapabilities } from '../../logic/use_gap_auto_fill_capabilities'; +import { GapAutoFillLogsFlyout } from '../gap_auto_fill_logs'; + +export interface RuleSettingsModalProps { + isOpen: boolean; + onClose: () => void; +} + +export const RuleSettingsModal: React.FC = ({ isOpen, onClose }) => { + const { canEditGapAutoFill, canAccessGapAutoFill } = useGapAutoFillCapabilities(); + const [isModalOpen, setIsModalOpen] = useState(isOpen); + const query = useGetGapAutoFillScheduler({ enabled: canAccessGapAutoFill }); + const createMutation = useCreateGapAutoFillScheduler(); + const updateMutation = useUpdateGapAutoFillScheduler(); + const { addSuccess, addError } = useAppToasts(); + + const [enabled, setEnabled] = useState(false); + const [isLogsFlyoutOpen, setIsLogsFlyoutOpen] = useState(false); + + const gapAutoFillScheduler = query.data; + + useEffect(() => { + if (isOpen && query.data) { + const isEnabled = query.data?.enabled ?? false; + setEnabled(isEnabled); + } + }, [isOpen, query.data]); + + const isSaving = createMutation.isLoading || updateMutation.isLoading; + const isLoadingGapAutoFillScheduler = query.isLoading; + + const onSave = async () => { + try { + if (!gapAutoFillScheduler) { + await createMutation.mutateAsync(); + } else { + await updateMutation.mutateAsync({ ...gapAutoFillScheduler, enabled }); + } + addSuccess({ + title: i18n.AUTO_GAP_FILL_TOAST_TITLE, + text: i18n.AUTO_GAP_FILL_TOAST_TEXT, + }); + } catch (err) { + addError(err, { title: i18n.AUTO_GAP_FILL_TOAST_TITLE }); + } + }; + + if (!canAccessGapAutoFill) return null; + + const isFormElementDisabled = isSaving || isLoadingGapAutoFillScheduler || !canEditGapAutoFill; + + const isSaveBtnDisabled = (!enabled && !gapAutoFillScheduler) || isFormElementDisabled; + + return ( +
+ {isModalOpen && ( + { + onClose(); + }} + aria-labelledby={i18n.RULE_SETTINGS_TITLE} + data-test-subj="rule-settings-modal" + > + + {i18n.RULE_SETTINGS_TITLE} + + + + + +

{i18n.GAP_AUTO_FILL_HEADER}

+
+ + + + setEnabled(e.target.checked)} + disabled={isSaving || !canEditGapAutoFill || isLoadingGapAutoFillScheduler} + /> + + + +

+ { + setIsLogsFlyoutOpen(true); + setIsModalOpen(false); + }} + data-test-subj="gap-fill-scheduler-logs-link" + > + + + ), + }} + /> +

+
+ +
+ + {i18n.RULE_SETTINGS_MODAL_CANCEL} + + {i18n.RULE_SETTINGS_MODAL_SAVE} + + +
+ )} + onClose()} /> +
+ ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/components/rules_with_gaps_overview_panel/gap_auto_fill_status.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/components/rules_with_gaps_overview_panel/gap_auto_fill_status.tsx new file mode 100644 index 0000000000000..bfb0c36d88a85 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/components/rules_with_gaps_overview_panel/gap_auto_fill_status.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiIconTip, EuiText } from '@elastic/eui'; +import { useBoolState } from '../../../../common/hooks/use_bool_state'; +import { useGetGapAutoFillScheduler } from '../../api/hooks/use_gap_auto_fill_scheduler'; +import { GAP_AUTO_FILL_DESCRIPTION } from '../../translations'; +import * as i18n from './translations'; +import { RuleSettingsModal } from '../rule_settings_modal'; +import { useGapAutoFillCapabilities } from '../../logic/use_gap_auto_fill_capabilities'; + +export const GapAutoFillStatus = React.memo(() => { + const { canAccessGapAutoFill, loading } = useGapAutoFillCapabilities(); + const { data: gapAutoFillScheduler, isLoading: isGapAutoFillSchedulerLoading } = + useGetGapAutoFillScheduler({ enabled: canAccessGapAutoFill && !loading }); + const [isRuleSettingsModalOpen, openRuleSettingsModal, closeRuleSettingsModal] = useBoolState(); + const isStatusLoading = isGapAutoFillSchedulerLoading && !gapAutoFillScheduler; + const isEnabled = gapAutoFillScheduler?.enabled ?? false; + + if (!canAccessGapAutoFill) { + return null; + } + + const badgeLabel = isStatusLoading + ? i18n.RULE_GAPS_OVERVIEW_PANEL_AUTO_GAP_FILL_STATUS_LOADING + : isEnabled + ? i18n.RULE_GAPS_OVERVIEW_PANEL_AUTO_GAP_FILL_STATUS_ON + : i18n.RULE_GAPS_OVERVIEW_PANEL_AUTO_GAP_FILL_STATUS_OFF; + const badgeColor = isEnabled ? 'success' : 'default'; + + return ( + + + + + + + {i18n.RULE_GAPS_OVERVIEW_PANEL_AUTO_GAP_FILL_STATUS_LABEL} + + + + + {badgeLabel} + + + {isRuleSettingsModalOpen && ( + + )} + + ); +}); + +GapAutoFillStatus.displayName = 'GapAutoFillStatus'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/components/rules_with_gaps_overview_panel/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/components/rules_with_gaps_overview_panel/index.tsx index 95ffa60e66f49..f155544816602 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/components/rules_with_gaps_overview_panel/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/components/rules_with_gaps_overview_panel/index.tsx @@ -7,17 +7,17 @@ import React, { useState, useEffect } from 'react'; import { + EuiBadge, EuiButton, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiFilterButton, + EuiFilterGroup, EuiFlexGroup, EuiFlexItem, EuiPanel, - EuiText, - EuiContextMenuPanel, EuiPopover, - EuiContextMenuItem, - EuiBadge, - EuiFilterButton, - EuiFilterGroup, + EuiText, EuiToolTip, } from '@elastic/eui'; @@ -28,6 +28,7 @@ import { useGetRuleIdsWithGaps } from '../../api/hooks/use_get_rule_ids_with_gap import { defaultRangeValue, GapRangeValue } from '../../constants'; import { ManualRuleRunEventTypes } from '../../../../common/lib/telemetry/events/manual_rule_run/types'; import { useKibana } from '../../../../common/lib/kibana'; +import { GapAutoFillStatus } from './gap_auto_fill_status'; export const RulesWithGapsOverviewPanel = () => { const { @@ -174,6 +175,10 @@ export const RulesWithGapsOverviewPanel = () => { + + + + ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/components/rules_with_gaps_overview_panel/translations.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/components/rules_with_gaps_overview_panel/translations.tsx index 622e01d111747..3879fd2e2b918 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/components/rules_with_gaps_overview_panel/translations.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/components/rules_with_gaps_overview_panel/translations.tsx @@ -47,3 +47,31 @@ export const RULE_GAPS_OVERVIEW_PANEL_TOOLTIP_TEXT = i18n.translate( defaultMessage: 'Rules with unfilled gaps / Rules with gaps being filled now', } ); + +export const RULE_GAPS_OVERVIEW_PANEL_AUTO_GAP_FILL_STATUS_LABEL = i18n.translate( + 'xpack.securitySolution.ruleGapsOverviewPanel.autoGapFillStatusLabel', + { + defaultMessage: 'Auto gap fill status:', + } +); + +export const RULE_GAPS_OVERVIEW_PANEL_AUTO_GAP_FILL_STATUS_ON = i18n.translate( + 'xpack.securitySolution.ruleGapsOverviewPanel.autoGapFillStatusOn', + { + defaultMessage: 'On', + } +); + +export const RULE_GAPS_OVERVIEW_PANEL_AUTO_GAP_FILL_STATUS_OFF = i18n.translate( + 'xpack.securitySolution.ruleGapsOverviewPanel.autoGapFillStatusOff', + { + defaultMessage: 'Off', + } +); + +export const RULE_GAPS_OVERVIEW_PANEL_AUTO_GAP_FILL_STATUS_LOADING = i18n.translate( + 'xpack.securitySolution.ruleGapsOverviewPanel.autoGapFillStatusLoading', + { + defaultMessage: 'Loading', + } +); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/constants.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/constants.ts index 597ccbd5cbcf1..afeab84c0da51 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/constants.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/constants.ts @@ -12,3 +12,12 @@ export enum GapRangeValue { } export const defaultRangeValue = GapRangeValue.LAST_24_H; + +export const DEFAULT_GAP_AUTO_FILL_SCHEDULER_NAME = 'Security Solution Gap Auto Fill Scheduler'; +export const DEFAULT_GAP_AUTO_FILL_SCHEDULER_INTERVAL = '5m'; +export const DEFAULT_GAP_AUTO_FILL_SCHEDULER_MAX_BACKFILLS = 100; +export const DEFAULT_GAP_AUTO_FILL_SCHEDULER_NUM_RETRIES = 3; +export const DEFAULT_GAP_AUTO_FILL_SCHEDULER_GAP_FILL_RANGE = 'now-90d'; +export const DEFAULT_GAP_AUTO_FILL_SCHEDULER_SCOPE = ['security_solution']; +export const DEFAULT_GAP_AUTO_FILL_SCHEDULER_ID_PREFIX = + 'security-solution-gap-auto-fill-scheduler'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/logic/use_gap_auto_fill_capabilities.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/logic/use_gap_auto_fill_capabilities.test.tsx new file mode 100644 index 0000000000000..677e6cefe5d59 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/logic/use_gap_auto_fill_capabilities.test.tsx @@ -0,0 +1,66 @@ +/* + * 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 { renderHook } from '@testing-library/react'; +import { useGapAutoFillCapabilities } from './use_gap_auto_fill_capabilities'; + +const mockUseLicense = jest.fn(); +const mockUseUserData = jest.fn(); +const mockUseIsExperimentalFeatureEnabled = jest.fn(); + +jest.mock('../../../common/hooks/use_license', () => ({ + useLicense: () => mockUseLicense(), +})); + +jest.mock('../../../detections/components/user_info', () => ({ + useUserData: () => mockUseUserData(), +})); + +jest.mock('../../../common/hooks/use_experimental_features', () => ({ + useIsExperimentalFeatureEnabled: () => mockUseIsExperimentalFeatureEnabled(), +})); + +describe('useGapAutoFillCapabilities', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseUserData.mockReturnValue([{ canUserCRUD: true }, jest.fn()]); + mockUseLicense.mockReturnValue({ + isEnterprise: () => true, + }); + mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); + }); + + it('returns edit access when license and CRUD permissions are available', () => { + const { result } = renderHook(() => useGapAutoFillCapabilities()); + + expect(result.current.canAccessGapAutoFill).toBe(true); + expect(result.current.canEditGapAutoFill).toBe(true); + }); + + it('denies access when license is below enterprise', () => { + mockUseLicense.mockReturnValue({ + isEnterprise: () => false, + }); + + const { result } = renderHook(() => useGapAutoFillCapabilities()); + + expect(result.current.canAccessGapAutoFill).toBe(false); + expect(result.current.canEditGapAutoFill).toBe(false); + }); + + it('denies edit rights when license is enterprise but user lacks CRUD', () => { + mockUseLicense.mockReturnValue({ + isEnterprise: () => true, + }); + mockUseUserData.mockReturnValue([{ canUserCRUD: false }, jest.fn()]); + + const { result } = renderHook(() => useGapAutoFillCapabilities()); + + expect(result.current.canAccessGapAutoFill).toBe(true); + expect(result.current.canEditGapAutoFill).toBe(false); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/logic/use_gap_auto_fill_capabilities.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/logic/use_gap_auto_fill_capabilities.ts new file mode 100644 index 0000000000000..67050e1a170ca --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/logic/use_gap_auto_fill_capabilities.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { useLicense } from '../../../common/hooks/use_license'; +import { useUserData } from '../../../detections/components/user_info'; +import { hasUserCRUDPermission } from '../../../common/utils/privileges'; +import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; + +/** + * Centralized capability helper for everything related to the rule gaps auto-fill feature. () + */ +export const useGapAutoFillCapabilities = () => { + const license = useLicense(); + const [{ canUserCRUD, loading }] = useUserData(); + const gapAutoFillSchedulerEnabled = useIsExperimentalFeatureEnabled( + 'gapAutoFillSchedulerEnabled' + ); + const hasEnterpriseLicense = license.isEnterprise(); + const hasCrudPrivileges = hasUserCRUDPermission(canUserCRUD); + + return useMemo( + () => ({ + loading, + hasEnterpriseLicense, + hasCrudPrivileges, + canAccessGapAutoFill: gapAutoFillSchedulerEnabled && hasEnterpriseLicense, + canEditGapAutoFill: gapAutoFillSchedulerEnabled && hasEnterpriseLicense && hasCrudPrivileges, + }), + [gapAutoFillSchedulerEnabled, hasEnterpriseLicense, hasCrudPrivileges, loading] + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/translations.ts index c2297e237100b..680074912d72f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/translations.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/translations.ts @@ -213,3 +213,67 @@ export const BACKFILLS_TABLE_COLUMN_ACTION = i18n.translate( defaultMessage: 'Action', } ); + +// Rule Settings modal +export const RULE_SETTINGS_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleSettings.title', + { + defaultMessage: 'Rule settings', + } +); + +export const GAP_AUTO_FILL_HEADER = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleSettings.autoGapFillHeader', + { + defaultMessage: 'Auto gap fill settings', + } +); + +export const GAP_AUTO_FILL_TOGGLE_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleSettings.autoGapFillToggle', + { + defaultMessage: 'Enable auto gap fill', + } +); + +export const GAP_AUTO_FILL_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleSettings.autoGapFillDescription', + { + defaultMessage: 'Detected run gaps will be automatically filled.', + } +); + +export const RUN_SCHEDULE_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleSettings.runScheduleLabel', + { + defaultMessage: 'Run schedule', + } +); + +export const AUTO_GAP_FILL_TOAST_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleSettings.save', + { + defaultMessage: 'Auto gap fill', + } +); + +export const AUTO_GAP_FILL_TOAST_TEXT = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleSettings.saveSuccessToastText', + { + defaultMessage: 'Auto gap fill settings updated successfully', + } +); + +export const RULE_SETTINGS_MODAL_CANCEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleSettings.cancel', + { + defaultMessage: 'Cancel', + } +); + +export const RULE_SETTINGS_MODAL_SAVE = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleSettings.save', + { + defaultMessage: 'Save', + } +); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/types.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/types.ts index 223c6ad97aac3..19cdc48de5f02 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/types.ts @@ -36,3 +36,41 @@ export interface ScheduleBackfillProps { ruleIds: string[]; timeRange: TimeRange; } + +export interface GapAutoFillSchedulerBase { + id: string; + name: string; + scope: string[]; + schedule: { interval: string }; + ruleTypes: { type: string; consumer: string }[]; + gapFillRange: string; + maxBackfills: number; + numRetries: number; + enabled: boolean; +} + +export type GapAutoFillSchedulerResponse = GapAutoFillSchedulerBase & { + createdBy: string | null; + updatedBy: string | null; + createdAt: string; + updatedAt: string; +}; + +export interface GapAutoFillSchedulerLogEntry { + timestamp: string; + status: string; + message: string; + results: { + ruleId: string; + processedGaps: number; + status: string; + error: string; + }[]; +} + +export interface GapAutoFillSchedulerLogsResponse { + data: GapAutoFillSchedulerLogEntry[]; + total: number; + page: number; + perPage: number; +} diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/index.tsx index 5929d988eb1f8..d8560cc952037 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/index.tsx @@ -38,10 +38,13 @@ import { CREATE_NEW_RULE_TOUR_ANCHOR, RuleFeatureTour, } from '../../components/rules_table/feature_tour/rules_feature_tour'; +import { RuleSettingsModal } from '../../../rule_gaps/components/rule_settings_modal'; +import { useGapAutoFillCapabilities } from '../../../rule_gaps/logic/use_gap_auto_fill_capabilities'; const RulesPageComponent: React.FC = () => { const [isImportModalVisible, showImportModal, hideImportModal] = useBoolState(); const [isValueListFlyoutVisible, showValueListFlyout, hideValueListFlyout] = useBoolState(); + const [isRuleSettingsModalOpen, openRuleSettingsModal, closeRuleSettingsModal] = useBoolState(); const kibanaServices = useKibana().services; const { navigateToApp } = kibanaServices.application; @@ -62,6 +65,7 @@ const RulesPageComponent: React.FC = () => { needsIndex: needsListsIndex, } = useListsConfig(); const loading = userInfoLoading || listsConfigLoading; + const { canAccessGapAutoFill } = useGapAutoFillCapabilities(); const isDoesNotMatchForIndicatorMatchRuleEnabled = useIsExperimentalFeatureEnabled( 'doesNotMatchForIndicatorMatchRuleEnabled' @@ -105,6 +109,15 @@ const RulesPageComponent: React.FC = () => { + {canAccessGapAutoFill && ( + + {i18n.RULE_SETTINGS_TITLE} + + )} @@ -151,6 +164,9 @@ const RulesPageComponent: React.FC = () => { {isDoesNotMatchForIndicatorMatchRuleEnabled && } + {isRuleSettingsModalOpen && canAccessGapAutoFill && ( + + )} { + visitRulesManagementTable(); + cy.get(RULES_MONITORING_TAB).click(); + cy.get(RULE_GAPS_OVERVIEW_PANEL).should('exist'); +}; + +const openRuleSettingsModalViaBadge = () => { + cy.get(RULE_GAPS_OVERVIEW_PANEL).within(() => { + cy.get(GAP_AUTO_FILL_STATUS_BADGE).click(); + }); + cy.get(RULE_SETTINGS_MODAL).should('exist'); +}; + +const closeRuleSettingsModal = () => { + cy.get(RULE_SETTINGS_MODAL).within(() => { + cy.contains('button', 'Cancel').click(); + }); +}; + +const ensureAutoGapFillEnabledViaUi = () => { + visitMonitoringTab(); + openRuleSettingsModalViaBadge(); + cy.get(RULE_SETTINGS_ENABLE_SWITCH) + .invoke('attr', 'aria-checked') + .then((checked) => { + if (checked === 'true') { + closeRuleSettingsModal(); + return; + } + + cy.get(RULE_SETTINGS_ENABLE_SWITCH).click(); + cy.get(RULE_SETTINGS_SAVE_BUTTON).click(); + cy.contains(TOASTER_BODY, 'Auto gap fill settings updated successfully'); + cy.waitUntil(() => + getGapAutoFillSchedulerApi().then( + (response) => response.status === 200 && response.body.enabled === true + ) + ); + closeRuleSettingsModal(); + }); +}; + +describe('Rule gaps auto fill status', { tags: ['@ess'] }, () => { + describe('Platinum user flows', () => { + beforeEach(() => { + login(); + deleteAlertsAndRules(); + deleteGapAutoFillScheduler(); + createRule( + getCustomQueryRuleParams({ rule_id: '1', name: 'Rule 1', interval: '1m', from: 'now-1m' }) + ); + }); + + afterEach(() => { + deleteGapAutoFillScheduler(); + }); + + it('Enable/disable auto gap fill', () => { + ensureAutoGapFillEnabledViaUi(); + + openRuleSettingsModalViaBadge(); + + cy.get(RULE_SETTINGS_MODAL).should('exist'); + cy.get(RULE_SETTINGS_ENABLE_SWITCH).should('have.attr', 'aria-checked', 'true').click(); + cy.get(RULE_SETTINGS_SAVE_BUTTON).should('not.be.disabled').click(); + cy.contains(TOASTER_BODY, 'Auto gap fill settings updated successfully'); + closeRuleSettingsModal(); + + cy.waitUntil(() => + getGapAutoFillSchedulerApi().then( + (response) => response.status === 200 && response.body.enabled === false + ) + ); + }); + + it('View gap fill scheduler logs and filter by status', () => { + ensureAutoGapFillEnabledViaUi(); + + openRuleSettingsModalViaBadge(); + cy.get(RULE_SETTINGS_MODAL).should('exist'); + + // Click on the logs link to open the flyout + cy.get(GAP_FILL_SCHEDULER_LOGS_LINK).click(); + cy.get(GAP_AUTO_FILL_LOGS_FLYOUT).should('exist'); + + // Wait for the table to load + cy.get(GAP_AUTO_FILL_LOGS_TABLE).should('be.visible'); + + // By default, filter is set to success/error + // Check table is displayed + cy.get(GAP_AUTO_FILL_LOGS_TABLE).should('exist'); + + // Open the status filter popover + getGapAutoFillLogsTableRows().then(() => { + cy.get(GAP_AUTO_FILL_LOGS_STATUS_FILTER_POPOVER_BUTTON).click(); + + // Wait for the popover to be visible and interact with selectable items + cy.get('[data-test-subj="gap-auto-fill-logs-status-filter-item"]').should('be.visible'); + + // Find and click on "Success" to uncheck it (it's checked by default) + cy.get('[data-test-subj="gap-auto-fill-logs-status-filter-item"]') + .contains('Success') + .click(); + + // Find and click on "Error" to uncheck it (it's checked by default) + cy.get('[data-test-subj="gap-auto-fill-logs-status-filter-item"]') + .contains('Error') + .click(); + + // Find and click on "No gaps" to check it + cy.get('[data-test-subj="gap-auto-fill-logs-status-filter-item"]') + .contains('No gaps') + .click(); + + // Close the popover by clicking outside + cy.get('body').click(0, 0); + + // Verify the filter was applied - the table should update + cy.get(GAP_AUTO_FILL_LOGS_TABLE).should('be.visible'); + + // Verify that after filtering, rows have the expected status in the status column + getGapAutoFillLogsTableRows() + .should('exist') + .each(($row) => { + cy.wrap($row).find('td').eq(1).contains('No gaps'); + }); + }); + }); + }); + + describe('Read-only user', () => { + beforeEach(() => { + deleteAlertsAndRules(); + deleteGapAutoFillScheduler(); + createRule( + getCustomQueryRuleParams({ rule_id: '1', name: 'Rule 1', interval: '1m', from: 'now-1m' }) + ); + login(); + ensureAutoGapFillEnabledViaUi(); + login(ROLES.t1_analyst); + }); + + afterEach(() => { + deleteGapAutoFillScheduler(); + }); + + it('shows the modal but disables edits for users without CRUD permissions', () => { + visitRulesManagementTable(); + cy.get(RULES_MONITORING_TAB).click(); + + cy.get(GAP_AUTO_FILL_STATUS_BADGE).click(); + cy.get(RULE_SETTINGS_MODAL).should('exist'); + cy.get(RULE_SETTINGS_ENABLE_SWITCH).should('be.disabled'); + cy.get(RULE_SETTINGS_SAVE_BUTTON).should('be.disabled'); + }); + }); +}); diff --git a/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_gaps/auto_gap_fill_basic_license.cy.ts b/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_gaps/auto_gap_fill_basic_license.cy.ts new file mode 100644 index 0000000000000..158a7b84e1967 --- /dev/null +++ b/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_gaps/auto_gap_fill_basic_license.cy.ts @@ -0,0 +1,39 @@ +/* + * 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 { + GAP_AUTO_FILL_STATUS_BADGE, + RULE_GAPS_OVERVIEW_PANEL, +} from '../../../../screens/rule_gaps'; + +import { deleteAlertsAndRules } from '../../../../tasks/api_calls/common'; +import { deleteGapAutoFillScheduler } from '../../../../tasks/api_calls/gaps'; +import { RULES_MONITORING_TAB } from '../../../../screens/alerts_detection_rules'; +import { startBasicLicense } from '../../../../tasks/api_calls/licensing'; +import { login } from '../../../../tasks/login'; +import { visitRulesManagementTable } from '../../../../tasks/rules_management'; +import { createRule } from '../../../../tasks/api_calls/rules'; +import { getCustomQueryRuleParams } from '../../../../objects/rule'; + +describe('Rule gaps auto fill status - Basic license', { tags: ['@ess'] }, () => { + beforeEach(() => { + login(); + deleteAlertsAndRules(); + deleteGapAutoFillScheduler(); + startBasicLicense(); + createRule( + getCustomQueryRuleParams({ rule_id: '1', name: 'Rule 1', interval: '1m', from: 'now-1m' }) + ); + }); + + it('hides the badge for basic licenses', () => { + visitRulesManagementTable(); + cy.get(RULES_MONITORING_TAB).click(); + cy.get(RULE_GAPS_OVERVIEW_PANEL).should('exist'); + cy.get(GAP_AUTO_FILL_STATUS_BADGE).should('not.exist'); + }); +}); diff --git a/x-pack/solutions/security/test/security_solution_cypress/cypress/screens/rule_gaps.ts b/x-pack/solutions/security/test/security_solution_cypress/cypress/screens/rule_gaps.ts new file mode 100644 index 0000000000000..6facb8223d4e3 --- /dev/null +++ b/x-pack/solutions/security/test/security_solution_cypress/cypress/screens/rule_gaps.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const RULE_GAPS_OVERVIEW_PANEL = '[data-test-subj="rule-with-gaps_overview-panel"]'; +export const GAP_AUTO_FILL_STATUS_BADGE = '[data-test-subj="gap-auto-fill-status-badge"]'; +export const RULE_SETTINGS_MODAL = '[data-test-subj="rule-settings-modal"]'; +export const RULE_SETTINGS_ENABLE_SWITCH = '[data-test-subj="rule-settings-enable-switch"]'; +export const RULE_SETTINGS_SAVE_BUTTON = '[data-test-subj="rule-settings-save"]'; +export const GAP_FILL_SCHEDULER_LOGS_LINK = '[data-test-subj="gap-fill-scheduler-logs-link"]'; +export const GAP_AUTO_FILL_LOGS_FLYOUT = '[data-test-subj="gap-auto-fill-logs"]'; +export const GAP_AUTO_FILL_LOGS_STATUS_FILTER = + '[data-test-subj="gap-auto-fill-logs-status-filter"]'; +export const GAP_AUTO_FILL_LOGS_STATUS_FILTER_POPOVER_BUTTON = + '[data-test-subj="gap-auto-fill-logs-status-filter-popoverButton"]'; +export const GAP_AUTO_FILL_LOGS_TABLE = '[data-test-subj="gap-auto-fill-logs-table"]'; diff --git a/x-pack/solutions/security/test/security_solution_cypress/cypress/tasks/api_calls/gaps.ts b/x-pack/solutions/security/test/security_solution_cypress/cypress/tasks/api_calls/gaps.ts index 5b30021d71063..4dec0b46d0eab 100644 --- a/x-pack/solutions/security/test/security_solution_cypress/cypress/tasks/api_calls/gaps.ts +++ b/x-pack/solutions/security/test/security_solution_cypress/cypress/tasks/api_calls/gaps.ts @@ -8,7 +8,12 @@ import { INTERNAL_ALERTING_GAPS_FIND_API_PATH, INTERNAL_ALERTING_GAPS_FILL_BY_ID_API_PATH, + INTERNAL_ALERTING_GAPS_AUTO_FILL_SCHEDULER_API_PATH, } from '@kbn/alerting-plugin/common'; +import type { GapAutoFillSchedulerResponseBodyV1 } from '@kbn/alerting-plugin/common/routes/gaps/apis/gap_auto_fill_scheduler'; +import { DEFAULT_GAP_AUTO_FILL_SCHEDULER_ID_PREFIX } from '@kbn/security-solution-plugin/public/detection_engine/rule_gaps/constants'; +import { rootRequest } from './common'; +import { getSpaceUrl } from '../space'; export const interceptGetRuleGapsNoData = () => { cy.intercept('POST', `${INTERNAL_ALERTING_GAPS_FIND_API_PATH}*`, { @@ -188,3 +193,30 @@ export const interceptBulkFillRulesGaps = ({ } ).as('bulkFillRulesGaps'); }; + +const getSchedulerId = (spaceId = 'default') => + `${DEFAULT_GAP_AUTO_FILL_SCHEDULER_ID_PREFIX}-${spaceId}`; + +const getSchedulerUrl = (spaceId = 'default') => + getSpaceUrl( + spaceId, + `${INTERNAL_ALERTING_GAPS_AUTO_FILL_SCHEDULER_API_PATH}/${getSchedulerId(spaceId)}` + ); + +export const deleteGapAutoFillScheduler = () => + cy.currentSpace().then((spaceId) => + rootRequest({ + method: 'DELETE', + url: getSchedulerUrl(spaceId), + failOnStatusCode: false, + }) + ); + +export const getGapAutoFillSchedulerApi = () => + cy.currentSpace().then((spaceId) => + rootRequest({ + method: 'GET', + url: getSchedulerUrl(spaceId), + failOnStatusCode: false, + }) + ); diff --git a/x-pack/solutions/security/test/security_solution_cypress/cypress/tasks/rule_details.ts b/x-pack/solutions/security/test/security_solution_cypress/cypress/tasks/rule_details.ts index a79d248670fc4..c13d3da6f7a34 100644 --- a/x-pack/solutions/security/test/security_solution_cypress/cypress/tasks/rule_details.ts +++ b/x-pack/solutions/security/test/security_solution_cypress/cypress/tasks/rule_details.ts @@ -60,6 +60,7 @@ import { import { addsFields, closeFieldsBrowser, filterFieldsBrowser } from './fields_browser'; import { visit } from './navigation'; import { LOCAL_DATE_PICKER_APPLY_BUTTON_TIMELINE } from '../screens/date_picker'; +import { GAP_AUTO_FILL_LOGS_TABLE } from '../screens/rule_gaps'; interface VisitRuleDetailsPageOptions { tab?: RuleDetailsTabs; @@ -235,3 +236,7 @@ export const filterGapsByStatus = (status: string) => { export const refreshGapsTable = () => { cy.get(RULE_GAPS_DATE_PICKER_APPLY_REFRESH).click(); }; + +export const getGapAutoFillLogsTableRows = () => { + return cy.get(GAP_AUTO_FILL_LOGS_TABLE).find('tbody tr'); +};