diff --git a/packages/kbn-check-mappings-update-cli/current_fields.json b/packages/kbn-check-mappings-update-cli/current_fields.json index e4a3deb5bc1e..bac3638c75bf 100644 --- a/packages/kbn-check-mappings-update-cli/current_fields.json +++ b/packages/kbn-check-mappings-update-cli/current_fields.json @@ -958,6 +958,9 @@ "installCount", "unInstallCount" ], + "scheduled_report": [ + "createdBy" + ], "search": [ "description", "title" diff --git a/packages/kbn-check-mappings-update-cli/current_mappings.json b/packages/kbn-check-mappings-update-cli/current_mappings.json index 282c6c8680ee..616b23f3855f 100644 --- a/packages/kbn-check-mappings-update-cli/current_mappings.json +++ b/packages/kbn-check-mappings-update-cli/current_mappings.json @@ -3119,6 +3119,14 @@ } } }, + "scheduled_report": { + "dynamic": false, + "properties": { + "createdBy": { + "type": "keyword" + } + } + }, "search": { "dynamic": false, "properties": { diff --git a/src/core/server/integration_tests/saved_objects/registration/type_registrations.test.ts b/src/core/server/integration_tests/saved_objects/registration/type_registrations.test.ts index 649a85445751..ce87be49ce7e 100644 --- a/src/core/server/integration_tests/saved_objects/registration/type_registrations.test.ts +++ b/src/core/server/integration_tests/saved_objects/registration/type_registrations.test.ts @@ -126,6 +126,7 @@ const previouslyRegisteredTypes = [ 'query', 'rules-settings', 'sample-data-telemetry', + 'scheduled_report', 'search', 'search-session', 'search-telemetry', diff --git a/src/platform/packages/private/kbn-reporting/common/errors.ts b/src/platform/packages/private/kbn-reporting/common/errors.ts index 45a299115bf1..3700602bd6f2 100644 --- a/src/platform/packages/private/kbn-reporting/common/errors.ts +++ b/src/platform/packages/private/kbn-reporting/common/errors.ts @@ -72,6 +72,13 @@ export class AuthenticationExpiredError extends ReportingError { } } +export class MissingAuthenticationError extends ReportingError { + static code = 'missing_authentication_header_error' as const; + public get code(): string { + return MissingAuthenticationError.code; + } +} + export class QueueTimeoutError extends ReportingError { static code = 'queue_timeout_error' as const; public get code(): string { diff --git a/src/platform/packages/private/kbn-reporting/common/routes.ts b/src/platform/packages/private/kbn-reporting/common/routes.ts index 4fb56700cda2..9b289877567f 100644 --- a/src/platform/packages/private/kbn-reporting/common/routes.ts +++ b/src/platform/packages/private/kbn-reporting/common/routes.ts @@ -24,8 +24,13 @@ export const INTERNAL_ROUTES = { DELETE_PREFIX: prefixInternalPath + '/jobs/delete', // docId is added to the final path DOWNLOAD_PREFIX: prefixInternalPath + '/jobs/download', // docId is added to the final path }, + SCHEDULED: { + LIST: prefixInternalPath + '/scheduled/list', + BULK_DISABLE: prefixInternalPath + '/scheduled/bulk_disable', + }, HEALTH: prefixInternalPath + '/_health', GENERATE_PREFIX: prefixInternalPath + '/generate', // exportTypeId is added to the final path + SCHEDULE_PREFIX: prefixInternalPath + '/schedule', // exportTypeId is added to the final path }; const prefixPublicPath = '/api/reporting'; diff --git a/src/platform/packages/private/kbn-reporting/common/types.ts b/src/platform/packages/private/kbn-reporting/common/types.ts index 8eed3f9e1de7..e24520964d26 100644 --- a/src/platform/packages/private/kbn-reporting/common/types.ts +++ b/src/platform/packages/private/kbn-reporting/common/types.ts @@ -66,6 +66,7 @@ export interface BaseParams { objectType: string; title: string; version: string; // to handle any state migrations + forceNow?: string; layout?: LayoutParams; // png & pdf only pagingStrategy?: CsvPagingStrategy; // csv only } @@ -152,6 +153,7 @@ export interface ReportSource { created_at: string; // timestamp in UTC '@timestamp'?: string; // creation timestamp, only used for data streams compatibility status: JOB_STATUS; + scheduled_report_id?: string; /* * `output` is only populated if the report job is completed or failed. diff --git a/src/platform/packages/private/kbn-reporting/server/check_license.test.ts b/src/platform/packages/private/kbn-reporting/server/check_license.test.ts index 70df06a9752b..a3b52a770737 100644 --- a/src/platform/packages/private/kbn-reporting/server/check_license.test.ts +++ b/src/platform/packages/private/kbn-reporting/server/check_license.test.ts @@ -49,6 +49,16 @@ describe('check_license', () => { it('should set management.jobTypes to undefined', () => { expect(checkLicense(exportTypesRegistry, undefined).management.jobTypes).toEqual(undefined); }); + + it('should set scheduledReports.showLinks to true', () => { + expect(checkLicense(exportTypesRegistry, undefined).scheduledReports.showLinks).toEqual(true); + }); + + it('should set scheduledReports.enableLinks to false', () => { + expect(checkLicense(exportTypesRegistry, undefined).scheduledReports.enableLinks).toEqual( + false + ); + }); }); describe('license information is not available', () => { @@ -82,6 +92,16 @@ describe('check_license', () => { it('should set management.jobTypes to undefined', () => { expect(checkLicense(exportTypesRegistry, license).management.jobTypes).toEqual(undefined); }); + + it('should set scheduledReports.showLinks to true', () => { + expect(checkLicense(exportTypesRegistry, license).scheduledReports.showLinks).toEqual(true); + }); + + it('should set scheduledReports.enableLinks to false', () => { + expect(checkLicense(exportTypesRegistry, license).scheduledReports.enableLinks).toEqual( + false + ); + }); }); describe('license information is available', () => { @@ -121,6 +141,18 @@ describe('check_license', () => { 'printable_pdf' ); }); + + it('should set scheduledReports.showLinks to true', () => { + expect(checkLicense(exportTypesRegistry, license).scheduledReports.showLinks).toEqual( + true + ); + }); + + it('should set scheduledReports.enableLinks to true', () => { + expect(checkLicense(exportTypesRegistry, license).scheduledReports.enableLinks).toEqual( + true + ); + }); }); describe('& license is expired', () => { @@ -147,6 +179,18 @@ describe('check_license', () => { it('should set management.jobTypes to undefined', () => { expect(checkLicense(exportTypesRegistry, license).management.jobTypes).toEqual(undefined); }); + + it('should set scheduledReports.showLinks to true', () => { + expect(checkLicense(exportTypesRegistry, license).scheduledReports.showLinks).toEqual( + true + ); + }); + + it('should set scheduledReports.enableLinks to false', () => { + expect(checkLicense(exportTypesRegistry, license).scheduledReports.enableLinks).toEqual( + false + ); + }); }); }); @@ -175,6 +219,12 @@ describe('check_license', () => { expect(checkLicense(exportTypesRegistry, license).management.jobTypes).toEqual([]); expect(checkLicense(exportTypesRegistry, license).management.jobTypes).toHaveLength(0); }); + + it('should set scheduledReports.showLinks to false', () => { + expect(checkLicense(exportTypesRegistry, license).scheduledReports.showLinks).toEqual( + false + ); + }); }); describe('& license is expired', () => { @@ -193,6 +243,12 @@ describe('check_license', () => { it('should set management.jobTypes to undefined', () => { expect(checkLicense(exportTypesRegistry, license).management.jobTypes).toEqual(undefined); }); + + it('should set scheduledReports.showLinks to true', () => { + expect(checkLicense(exportTypesRegistry, license).scheduledReports.showLinks).toEqual( + true + ); + }); }); }); }); diff --git a/src/platform/packages/private/kbn-reporting/server/check_license.ts b/src/platform/packages/private/kbn-reporting/server/check_license.ts index 128542dc8eef..6a079b4ee61d 100644 --- a/src/platform/packages/private/kbn-reporting/server/check_license.ts +++ b/src/platform/packages/private/kbn-reporting/server/check_license.ts @@ -7,7 +7,14 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { ILicense } from '@kbn/licensing-plugin/server'; +import { ILicense, LicenseType } from '@kbn/licensing-plugin/server'; +import { + LICENSE_TYPE_CLOUD_STANDARD, + LICENSE_TYPE_ENTERPRISE, + LICENSE_TYPE_GOLD, + LICENSE_TYPE_PLATINUM, + LICENSE_TYPE_TRIAL, +} from '@kbn/reporting-common'; import type { ExportType } from '.'; import { ExportTypesRegistry } from './export_types_registry'; @@ -18,6 +25,14 @@ export interface LicenseCheckResult { jobTypes?: string[]; } +const scheduledReportValidLicenses: LicenseType[] = [ + LICENSE_TYPE_TRIAL, + LICENSE_TYPE_CLOUD_STANDARD, + LICENSE_TYPE_GOLD, + LICENSE_TYPE_PLATINUM, + LICENSE_TYPE_ENTERPRISE, +]; + const messages = { getUnavailable: () => { return 'You cannot use Reporting because license information is not available at this time.'; @@ -60,6 +75,42 @@ const makeManagementFeature = (exportTypes: ExportType[]) => { }; }; +const makeScheduledReportsFeature = () => { + return { + id: 'scheduledReports', + checkLicense: (license?: ILicense) => { + if (!license || !license.type) { + return { + showLinks: true, + enableLinks: false, + message: messages.getUnavailable(), + }; + } + + if (!license.isActive) { + return { + showLinks: true, + enableLinks: false, + message: messages.getExpired(license), + }; + } + + if (!scheduledReportValidLicenses.includes(license.type)) { + return { + showLinks: false, + enableLinks: false, + message: `Your ${license.type} license does not support Scheduled reports. Please upgrade your license.`, + }; + } + + return { + showLinks: true, + enableLinks: true, + }; + }, + }; +}; + const makeExportTypeFeature = (exportType: ExportType) => { return { id: exportType.id, @@ -104,6 +155,7 @@ export function checkLicense( const reportingFeatures = [ ...exportTypes.map(makeExportTypeFeature), makeManagementFeature(exportTypes), + makeScheduledReportsFeature(), ]; return reportingFeatures.reduce((result, feature) => { diff --git a/x-pack/platform/plugins/private/canvas/server/feature.test.ts b/x-pack/platform/plugins/private/canvas/server/feature.test.ts index 7f71bafd4170..6fde4788a9c8 100644 --- a/x-pack/platform/plugins/private/canvas/server/feature.test.ts +++ b/x-pack/platform/plugins/private/canvas/server/feature.test.ts @@ -107,7 +107,9 @@ it('Provides a feature declaration ', () => { "minimumLicense": "gold", "name": "Generate PDF reports", "savedObject": Object { - "all": Array [], + "all": Array [ + "scheduled_report", + ], "read": Array [], }, "ui": Array [ @@ -216,7 +218,9 @@ it(`Calls on Reporting whether to include Generate PDF as a sub-feature`, () => "minimumLicense": "gold", "name": "Generate PDF reports", "savedObject": Object { - "all": Array [], + "all": Array [ + "scheduled_report", + ], "read": Array [], }, "ui": Array [ diff --git a/x-pack/platform/plugins/private/canvas/server/feature.ts b/x-pack/platform/plugins/private/canvas/server/feature.ts index daa8a8fc4aa4..4aa03b73425f 100644 --- a/x-pack/platform/plugins/private/canvas/server/feature.ts +++ b/x-pack/platform/plugins/private/canvas/server/feature.ts @@ -68,7 +68,7 @@ export function getCanvasFeature(plugins: { reporting?: ReportingStart }): Kiban includeIn: 'all', management: { insightsAndAlerting: ['reporting'] }, minimumLicense: 'gold', - savedObject: { all: [], read: [] }, + savedObject: { all: ['scheduled_report'], read: [] }, api: ['generateReport'], ui: ['generatePdf'], }, diff --git a/x-pack/platform/plugins/private/reporting/kibana.jsonc b/x-pack/platform/plugins/private/reporting/kibana.jsonc index a27970e2cec8..1a3b40c96ca4 100644 --- a/x-pack/platform/plugins/private/reporting/kibana.jsonc +++ b/x-pack/platform/plugins/private/reporting/kibana.jsonc @@ -16,6 +16,7 @@ "reporting" ], "requiredPlugins": [ + "actions", "data", "discover", "encryptedSavedObjects", diff --git a/x-pack/platform/plugins/private/reporting/server/core.ts b/x-pack/platform/plugins/private/reporting/server/core.ts index 35d36557f4bf..82adfffe42f9 100644 --- a/x-pack/platform/plugins/private/reporting/server/core.ts +++ b/x-pack/platform/plugins/private/reporting/server/core.ts @@ -23,6 +23,8 @@ import type { StatusServiceSetup, UiSettingsServiceStart, } from '@kbn/core/server'; +import { SECURITY_EXTENSION_ID } from '@kbn/core-saved-objects-server'; +import type { PluginSetupContract as ActionsPluginSetupContract } from '@kbn/actions-plugin/server'; import type { PluginStart as DataPluginStart } from '@kbn/data-plugin/server'; import type { DiscoverServerPluginStart } from '@kbn/discover-plugin/server'; import type { FeaturesPluginSetup } from '@kbn/features-plugin/server'; @@ -35,7 +37,7 @@ import { PngExportType } from '@kbn/reporting-export-types-png'; import type { ReportingConfigType } from '@kbn/reporting-server'; import { ExportType } from '@kbn/reporting-server'; import { ScreenshottingStart } from '@kbn/screenshotting-plugin/server'; -import type { SecurityPluginSetup } from '@kbn/security-plugin/server'; +import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/server'; import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common'; import type { SpacesPluginSetup } from '@kbn/spaces-plugin/server'; import type { @@ -52,11 +54,20 @@ import type { ReportingSetup } from '.'; import { createConfig } from './config'; import { reportingEventLoggerFactory } from './lib/event_logger/logger'; import type { IReport, ReportingStore } from './lib/store'; -import { ExecuteReportTask, ReportTaskParams } from './lib/tasks'; +import { + RunSingleReportTask, + ReportTaskParams, + RunScheduledReportTask, + ScheduledReportTaskParamsWithoutSpaceId, +} from './lib/tasks'; import type { ReportingPluginRouter } from './types'; import { EventTracker } from './usage'; +import { SCHEDULED_REPORT_SAVED_OBJECT_TYPE } from './saved_objects'; +import { EmailNotificationService } from './services/notifications/email_notification_service'; +import { API_PRIVILEGES } from './features'; export interface ReportingInternalSetup { + actions: ActionsPluginSetupContract; basePath: Pick; docLinks: DocLinksServiceSetup; encryptedSavedObjects: EncryptedSavedObjectsPluginSetup; @@ -83,6 +94,7 @@ export interface ReportingInternalStart { logger: Logger; notifications: NotificationsPluginStart; screenshotting?: ScreenshottingStart; + security?: SecurityPluginStart; securityService: SecurityServiceStart; taskManager: TaskManagerStartContract; } @@ -96,7 +108,8 @@ export class ReportingCore { private pluginStartDeps?: ReportingInternalStart; private readonly pluginSetup$ = new Rx.ReplaySubject(); // observe async background setupDeps each are done private readonly pluginStart$ = new Rx.ReplaySubject(); // observe async background startDeps - private executeTask: ExecuteReportTask; + private runSingleReportTask: RunSingleReportTask; + private runScheduledReportTask: RunScheduledReportTask; private config: ReportingConfigType; private executing: Set; private exportTypesRegistry = new ExportTypesRegistry(); @@ -117,7 +130,16 @@ export class ReportingCore { this.getExportTypes().forEach((et) => { this.exportTypesRegistry.register(et); }); - this.executeTask = new ExecuteReportTask(this, config, this.logger); + this.runSingleReportTask = new RunSingleReportTask({ + reporting: this, + config, + logger: this.logger, + }); + this.runScheduledReportTask = new RunScheduledReportTask({ + reporting: this, + config, + logger: this.logger, + }); this.getContract = () => ({ registerExportTypes: (id) => id, @@ -142,9 +164,10 @@ export class ReportingCore { et.setup(setupDeps); }); - const { executeTask } = this; + const { runSingleReportTask, runScheduledReportTask } = this; setupDeps.taskManager.registerTaskDefinitions({ - [executeTask.TYPE]: executeTask.getTaskDefinition(), + [runSingleReportTask.TYPE]: runSingleReportTask.getTaskDefinition(), + [runScheduledReportTask.TYPE]: runScheduledReportTask.getTaskDefinition(), }); } @@ -159,10 +182,17 @@ export class ReportingCore { et.start({ ...startDeps }); }); - const { taskManager } = startDeps; - const { executeTask } = this; + const { taskManager, notifications } = startDeps; + const emailNotificationService = new EmailNotificationService({ + notifications, + }); + + const { runSingleReportTask, runScheduledReportTask } = this; // enable this instance to generate reports - await Promise.all([executeTask.init(taskManager)]); + await Promise.all([ + runSingleReportTask.init(taskManager), + runScheduledReportTask.init(taskManager, emailNotificationService), + ]); } public pluginStop() { @@ -278,6 +308,18 @@ export class ReportingCore { }; } + public async canManageReportingForSpace(req: KibanaRequest): Promise { + const { security } = await this.getPluginStartDeps(); + const spaceId = this.getSpaceId(req); + const result = await security?.authz + .checkPrivilegesWithRequest(req) + .atSpace(spaceId ?? DEFAULT_SPACE_ID, { + kibana: [security?.authz.actions.api.get(API_PRIVILEGES.MANAGE_SCHEDULED_REPORTING)], + }); + + return result?.hasAllRequested ?? false; + } + /* * Gives synchronous access to the config */ @@ -322,13 +364,25 @@ export class ReportingCore { } public async scheduleTask(request: KibanaRequest, report: ReportTaskParams) { - return await this.executeTask.scheduleTask(request, report); + return await this.runSingleReportTask.scheduleTask(request, report); + } + + public async scheduleRecurringTask( + request: KibanaRequest, + report: ScheduledReportTaskParamsWithoutSpaceId + ) { + return await this.runScheduledReportTask.scheduleTask(request, report); } public async getStore() { return (await this.getPluginStartDeps()).store; } + public async getAuditLogger(request: KibanaRequest) { + const startDeps = await this.getPluginStartDeps(); + return startDeps.securityService.audit.asScoped(request); + } + public async getLicenseInfo() { const { license$ } = (await this.getPluginStartDeps()).licensing; const registry = this.getExportTypesRegistry(); @@ -354,6 +408,13 @@ export class ReportingCore { ); } + public validateNotificationEmails(emails: string[]): string | undefined { + const pluginSetupDeps = this.getPluginSetupDeps(); + return pluginSetupDeps.actions + .getActionsConfigurationUtilities() + .validateEmailAddresses(emails); + } + /* * Gives synchronous access to the setupDeps */ @@ -374,6 +435,24 @@ export class ReportingCore { return dataViews; } + public async getScopedSoClient(request: KibanaRequest) { + const { savedObjects } = await this.getPluginStartDeps(); + return savedObjects.getScopedClient(request, { + excludedExtensions: [SECURITY_EXTENSION_ID], + includedHiddenTypes: [SCHEDULED_REPORT_SAVED_OBJECT_TYPE], + }); + } + + public async getInternalSoClient() { + const { savedObjects } = await this.getPluginStartDeps(); + return savedObjects.createInternalRepository([SCHEDULED_REPORT_SAVED_OBJECT_TYPE]); + } + + public async getTaskManager() { + const { taskManager } = await this.getPluginStartDeps(); + return taskManager; + } + public async getDataService() { const startDeps = await this.getPluginStartDeps(); return startDeps.data; diff --git a/x-pack/platform/plugins/private/reporting/server/features.ts b/x-pack/platform/plugins/private/reporting/server/features.ts index a7971494b6c1..7a8674701a2d 100644 --- a/x-pack/platform/plugins/private/reporting/server/features.ts +++ b/x-pack/platform/plugins/private/reporting/server/features.ts @@ -9,6 +9,11 @@ import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server'; import { i18n } from '@kbn/i18n'; import type { FeaturesPluginSetup } from '@kbn/features-plugin/server'; import { KibanaFeatureScope } from '@kbn/features-plugin/common'; +import { SCHEDULED_REPORT_SAVED_OBJECT_TYPE } from './saved_objects'; + +export const API_PRIVILEGES = { + MANAGE_SCHEDULED_REPORTING: 'manage_scheduled_reports', +}; interface FeatureRegistrationOpts { features: FeaturesPluginSetup; @@ -37,4 +42,29 @@ export function registerFeatures({ isServerless, features }: FeatureRegistration } features.enableReportingUiCapabilities(); + + features.registerKibanaFeature({ + id: 'manageReporting', + name: i18n.translate('xpack.reporting.features.manageScheduledReportsFeatureName', { + defaultMessage: 'Manage Scheduled Reports', + }), + description: i18n.translate( + 'xpack.reporting.features.manageScheduledReportsFeatureDescription', + { + defaultMessage: 'View and manage scheduled reports for all users in this space.', + } + ), + category: DEFAULT_APP_CATEGORIES.management, + scope: [KibanaFeatureScope.Spaces, KibanaFeatureScope.Security], + app: [], + privileges: { + all: { + api: [API_PRIVILEGES.MANAGE_SCHEDULED_REPORTING], + savedObject: { all: [SCHEDULED_REPORT_SAVED_OBJECT_TYPE], read: [] }, + ui: ['show'], + }, + // No read-only mode currently supported + read: { disabled: true, savedObject: { all: [], read: [] }, ui: [] }, + }, + }); } diff --git a/x-pack/platform/plugins/private/reporting/server/lib/store/index.ts b/x-pack/platform/plugins/private/reporting/server/lib/store/index.ts index 168f95860081..148ad6238ce8 100644 --- a/x-pack/platform/plugins/private/reporting/server/lib/store/index.ts +++ b/x-pack/platform/plugins/private/reporting/server/lib/store/index.ts @@ -7,6 +7,7 @@ export { Report } from './report'; export { SavedReport } from './saved_report'; +export { ScheduledReport } from './scheduled_report'; export { ReportingStore } from './store'; export { IlmPolicyManager } from './ilm_policy_manager'; diff --git a/x-pack/platform/plugins/private/reporting/server/lib/store/report.test.ts b/x-pack/platform/plugins/private/reporting/server/lib/store/report.test.ts index 613485587324..7d9db3b75b01 100644 --- a/x-pack/platform/plugins/private/reporting/server/lib/store/report.test.ts +++ b/x-pack/platform/plugins/private/reporting/server/lib/store/report.test.ts @@ -63,6 +63,61 @@ describe('Class Report', () => { expect(report._id).toBeDefined(); }); + it('constructs Report instance when scheduled_task_id is defined', () => { + const report = new Report({ + _index: '.reporting-test-index-12345', + jobtype: 'test-report', + created_by: 'created_by_test_string', + max_attempts: 50, + payload: { + headers: 'payload_test_field', + objectType: 'testOt', + title: 'cool report', + version: '7.14.0', + browserTimezone: 'UTC', + }, + meta: { objectType: 'test' }, + timeout: 30000, + scheduled_report_id: 'foobar', + }); + + expect(report.toReportSource()).toMatchObject({ + attempts: 0, + completed_at: undefined, + created_by: 'created_by_test_string', + jobtype: 'test-report', + max_attempts: 50, + meta: { objectType: 'test' }, + payload: { headers: 'payload_test_field', objectType: 'testOt' }, + started_at: undefined, + status: 'pending', + timeout: 30000, + scheduled_report_id: 'foobar', + }); + expect(report.toReportTaskJSON()).toMatchObject({ + attempts: 0, + created_by: 'created_by_test_string', + index: '.reporting-test-index-12345', + jobtype: 'test-report', + meta: { objectType: 'test' }, + payload: { headers: 'payload_test_field', objectType: 'testOt' }, + }); + expect(report.toApiJSON()).toMatchObject({ + attempts: 0, + created_by: 'created_by_test_string', + index: '.reporting-test-index-12345', + jobtype: 'test-report', + max_attempts: 50, + payload: { objectType: 'testOt' }, + meta: { objectType: 'test' }, + status: 'pending', + timeout: 30000, + scheduled_report_id: 'foobar', + }); + + expect(report._id).toBeDefined(); + }); + it('updateWithEsDoc method syncs fields to sync ES metadata', () => { const report = new Report({ _index: '.reporting-test-index-12345', diff --git a/x-pack/platform/plugins/private/reporting/server/lib/store/report.ts b/x-pack/platform/plugins/private/reporting/server/lib/store/report.ts index 3f83ac577fb6..9edb44170851 100644 --- a/x-pack/platform/plugins/private/reporting/server/lib/store/report.ts +++ b/x-pack/platform/plugins/private/reporting/server/lib/store/report.ts @@ -41,6 +41,7 @@ export class Report implements Partial { public readonly status: ReportSource['status']; public readonly attempts: ReportSource['attempts']; + public readonly scheduled_report_id: ReportSource['scheduled_report_id']; // fields with undefined values exist in report jobs that have not been claimed public readonly kibana_name: ReportSource['kibana_name']; @@ -99,6 +100,7 @@ export class Report implements Partial { this.status = opts.status || JOB_STATUS.PENDING; this.output = opts.output || null; this.error = opts.error; + this.scheduled_report_id = opts.scheduled_report_id; this.queue_time_ms = fields?.queue_time_ms; this.execution_time_ms = fields?.execution_time_ms; @@ -142,6 +144,7 @@ export class Report implements Partial { space_id: this.space_id, output: this.output || null, metrics: this.metrics, + scheduled_report_id: this.scheduled_report_id, }; } @@ -191,6 +194,7 @@ export class Report implements Partial { payload: omit(this.payload, 'headers'), output: omit(this.output, 'content'), metrics: this.metrics, + scheduled_report_id: this.scheduled_report_id, }; } } diff --git a/x-pack/platform/plugins/private/reporting/server/lib/store/scheduled_report.test.ts b/x-pack/platform/plugins/private/reporting/server/lib/store/scheduled_report.test.ts new file mode 100644 index 000000000000..9210ed4c55d8 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/lib/store/scheduled_report.test.ts @@ -0,0 +1,178 @@ +/* + * 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 { Frequency } from '@kbn/task-manager-plugin/server'; +import { ScheduledReport } from '.'; + +const payload = { + headers: '', + title: 'Test Report', + browserTimezone: '', + objectType: 'test', + version: '8.0.0', +}; + +test('ScheduledReport should return correctly formatted outputs', () => { + const scheduledReport = new ScheduledReport({ + runAt: new Date('2023-10-01T00:00:00Z'), + kibanaId: 'instance-uuid', + kibanaName: 'kibana', + queueTimeout: 120000, + scheduledReport: { + id: 'report-so-id-111', + attributes: { + createdAt: new Date().toISOString(), + createdBy: 'test-user', + enabled: true, + jobType: 'test1', + meta: { objectType: 'test' }, + migrationVersion: '8.0.0', + payload: JSON.stringify(payload), + schedule: { rrule: { freq: Frequency.DAILY, interval: 2, tzid: 'UTC' } }, + title: 'Test Report', + }, + references: [], + type: 'scheduled-report', + }, + }); + expect(scheduledReport.toReportTaskJSON()).toEqual({ + attempts: 1, + created_at: '2023-10-01T00:00:00.000Z', + created_by: 'test-user', + id: expect.any(String), + index: '.kibana-reporting', + jobtype: 'test1', + meta: { + objectType: 'test', + }, + payload: { + browserTimezone: '', + forceNow: '2023-10-01T00:00:00.000Z', + headers: '', + objectType: 'test', + title: 'Test Report [2023-10-01T00:00:00.000Z]', + version: '8.0.0', + }, + }); + + expect(scheduledReport.toReportSource()).toEqual({ + attempts: 1, + max_attempts: 1, + created_at: '2023-10-01T00:00:00.000Z', + created_by: 'test-user', + jobtype: 'test1', + meta: { + objectType: 'test', + }, + migration_version: '7.14.0', + kibana_id: 'instance-uuid', + kibana_name: 'kibana', + output: null, + payload: { + browserTimezone: '', + forceNow: '2023-10-01T00:00:00.000Z', + headers: '', + objectType: 'test', + title: 'Test Report [2023-10-01T00:00:00.000Z]', + version: '8.0.0', + }, + scheduled_report_id: 'report-so-id-111', + status: 'processing', + started_at: expect.any(String), + process_expiration: expect.any(String), + timeout: 120000, + }); + + expect(scheduledReport.toApiJSON()).toEqual({ + id: expect.any(String), + index: '.kibana-reporting', + kibana_id: 'instance-uuid', + kibana_name: 'kibana', + jobtype: 'test1', + created_at: '2023-10-01T00:00:00.000Z', + created_by: 'test-user', + meta: { + objectType: 'test', + }, + timeout: 120000, + max_attempts: 1, + status: 'processing', + attempts: 1, + started_at: expect.any(String), + migration_version: '7.14.0', + output: {}, + queue_time_ms: expect.any(Number), + payload: { + browserTimezone: '', + forceNow: '2023-10-01T00:00:00.000Z', + objectType: 'test', + title: 'Test Report [2023-10-01T00:00:00.000Z]', + version: '8.0.0', + }, + scheduled_report_id: 'report-so-id-111', + }); +}); + +test('ScheduledReport should throw an error if report payload is malformed', () => { + const createInstance = () => { + return new ScheduledReport({ + runAt: new Date('2023-10-01T00:00:00Z'), + kibanaId: 'instance-uuid', + kibanaName: 'kibana', + queueTimeout: 120000, + scheduledReport: { + id: 'report-so-id-111', + attributes: { + createdAt: new Date().toISOString(), + createdBy: 'test-user', + enabled: true, + jobType: 'test1', + meta: { objectType: 'test' }, + migrationVersion: '8.0.0', + payload: 'abc', + schedule: { rrule: { freq: Frequency.DAILY, interval: 2, tzid: 'UTC' } }, + title: 'Test Report', + }, + references: [], + type: 'scheduled-report', + }, + }); + }; + expect(createInstance).toThrowErrorMatchingInlineSnapshot( + `"Unable to parse payload from scheduled_report saved object: SyntaxError: Unexpected token 'a', \\"abc\\" is not valid JSON"` + ); +}); + +test('ScheduledReport should throw an error if scheduled_report saved object is missing ID', () => { + const createInstance = () => { + return new ScheduledReport({ + runAt: new Date('2023-10-01T00:00:00Z'), + kibanaId: 'instance-uuid', + kibanaName: 'kibana', + queueTimeout: 120000, + // @ts-expect-error - missing id + scheduledReport: { + attributes: { + createdAt: new Date().toISOString(), + createdBy: 'test-user', + enabled: true, + jobType: 'test1', + meta: { objectType: 'test' }, + migrationVersion: '8.0.0', + payload: JSON.stringify(payload), + schedule: { rrule: { freq: Frequency.DAILY, interval: 2, tzid: 'UTC' } }, + title: 'Test Report', + }, + references: [], + type: 'scheduled-report', + }, + }); + }; + expect(createInstance).toThrowErrorMatchingInlineSnapshot( + `"Invalid scheduled_report saved object - no id"` + ); +}); diff --git a/x-pack/platform/plugins/private/reporting/server/lib/store/scheduled_report.ts b/x-pack/platform/plugins/private/reporting/server/lib/store/scheduled_report.ts new file mode 100644 index 000000000000..bfd2cbde1727 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/lib/store/scheduled_report.ts @@ -0,0 +1,69 @@ +/* + * 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 moment from 'moment'; +import { JOB_STATUS } from '@kbn/reporting-common'; + +import { SavedObject } from '@kbn/core/server'; +import { BasePayload } from '@kbn/reporting-common/types'; +import { Report } from './report'; +import { ScheduledReportType } from '../../types'; + +interface ConstructorOpts { + runAt: Date; + kibanaId: string; + kibanaName: string; + queueTimeout: number; + scheduledReport: SavedObject; +} + +export class ScheduledReport extends Report { + /* + * Create a report from a scheduled_report saved object + */ + constructor(opts: ConstructorOpts) { + const { kibanaId, kibanaName, runAt, scheduledReport, queueTimeout } = opts; + const now = moment.utc(); + const startTime = now.toISOString(); + const expirationTime = now.add(queueTimeout).toISOString(); + + let payload: BasePayload; + try { + payload = JSON.parse(scheduledReport.attributes.payload); + } catch (e) { + throw new Error(`Unable to parse payload from scheduled_report saved object: ${e}`); + } + + payload.forceNow = runAt.toISOString(); + payload.title = `${scheduledReport.attributes.title} [${runAt.toISOString()}]`; + + if (!scheduledReport.id) { + throw new Error(`Invalid scheduled_report saved object - no id`); + } + + super( + { + migration_version: scheduledReport.attributes.migrationVersion, + jobtype: scheduledReport.attributes.jobType, + created_at: runAt.toISOString(), + created_by: scheduledReport.attributes.createdBy as string | false, + payload, + meta: scheduledReport.attributes.meta, + status: JOB_STATUS.PROCESSING, + attempts: 1, + process_expiration: expirationTime, + kibana_id: kibanaId, + kibana_name: kibanaName, + max_attempts: 1, + started_at: startTime, + timeout: queueTimeout, + scheduled_report_id: scheduledReport.id, + }, + { queue_time_ms: [now.diff(moment.utc(runAt), 'milliseconds')] } + ); + } +} diff --git a/x-pack/platform/plugins/private/reporting/server/lib/store/store.test.ts b/x-pack/platform/plugins/private/reporting/server/lib/store/store.test.ts index 1f321a66846c..61d0a636e19f 100644 --- a/x-pack/platform/plugins/private/reporting/server/lib/store/store.test.ts +++ b/x-pack/platform/plugins/private/reporting/server/lib/store/store.test.ts @@ -60,6 +60,53 @@ describe('ReportingStore', () => { }); }); + it('uses report status if set', async () => { + const store = new ReportingStore(mockCore, mockLogger); + const mockReport = new Report({ + _index: '.reporting-mock', + attempts: 0, + created_by: 'username1', + jobtype: 'unknowntype', + status: 'processing', + payload: {}, + meta: {}, + } as any); + await expect(store.addReport(mockReport)).resolves.toMatchObject({ + _primary_term: undefined, + _seq_no: undefined, + attempts: 0, + completed_at: undefined, + created_by: 'username1', + jobtype: 'unknowntype', + payload: {}, + meta: {}, + status: 'processing', + }); + }); + + it('defaults to pending status if not set', async () => { + const store = new ReportingStore(mockCore, mockLogger); + const mockReport = new Report({ + _index: '.reporting-mock', + attempts: 0, + created_by: 'username1', + jobtype: 'unknowntype', + payload: {}, + meta: {}, + } as any); + await expect(store.addReport(mockReport)).resolves.toMatchObject({ + _primary_term: undefined, + _seq_no: undefined, + attempts: 0, + completed_at: undefined, + created_by: 'username1', + jobtype: 'unknowntype', + payload: {}, + meta: {}, + status: 'pending', + }); + }); + it('throws if options has invalid indexInterval', async () => { const reportingConfig = { index: '.reporting-test', @@ -181,6 +228,7 @@ describe('ReportingStore', () => { }, "process_expiration": undefined, "queue_time_ms": undefined, + "scheduled_report_id": undefined, "space_id": undefined, "started_at": undefined, "status": "pending", @@ -352,6 +400,48 @@ describe('ReportingStore', () => { `); }); + it('setReportWarning sets the status of a saved report to warning', async () => { + const store = new ReportingStore(mockCore, mockLogger); + const report = new SavedReport({ + _id: 'id-of-processing', + _index: '.reporting-test-index-12345', + _seq_no: 42, + _primary_term: 10002, + jobtype: 'test-report', + created_by: 'created_by_test_string', + max_attempts: 50, + payload: { + title: 'test report', + headers: 'rp_test_headers', + objectType: 'testOt', + browserTimezone: 'ABC', + version: '7.14.0', + }, + timeout: 30000, + }); + + await store.setReportWarning(report, { + output: { warnings: ['warning1'] }, + warning: 'warning2', + } as any); + + const [[updateCall]] = mockEsClient.update.mock.calls; + + const response = (updateCall as estypes.UpdateRequest)?.doc as Report; + expect(response.migration_version).toBe(`7.14.0`); + expect(response.status).toBe(`completed_with_warnings`); + expect(response.output).toMatchInlineSnapshot(` + Object { + "warnings": Array [ + "warning1", + "warning2", + ], + } + `); + expect(updateCall.if_seq_no).toBe(42); + expect(updateCall.if_primary_term).toBe(10002); + }); + describe('start', () => { class TestReportingStore extends ReportingStore { constructor(...args: ConstructorParameters) { diff --git a/x-pack/platform/plugins/private/reporting/server/lib/store/store.ts b/x-pack/platform/plugins/private/reporting/server/lib/store/store.ts index 671a9dbc924b..38fd5a6036c7 100644 --- a/x-pack/platform/plugins/private/reporting/server/lib/store/store.ts +++ b/x-pack/platform/plugins/private/reporting/server/lib/store/store.ts @@ -53,6 +53,11 @@ export type ReportCompletedFields = Required<{ output: Omit | null; }>; +export interface ReportWarningFields { + output: Omit; + warning: string; +} + /* * When searching for long-pending reports, we get a subset of fields */ @@ -145,8 +150,8 @@ export class ReportingStore { ...report.toReportSource(), ...sourceDoc({ process_expiration: new Date(0).toISOString(), - attempts: 0, - status: JOB_STATUS.PENDING, + attempts: report.attempts || 0, + status: report.status || JOB_STATUS.PENDING, }), }, }; @@ -337,4 +342,31 @@ export class ReportingStore { return body; } + + public async setReportWarning( + report: SavedReport, + warningInfo: ReportWarningFields + ): Promise> { + const { output, warning } = warningInfo; + const warnings: string[] = output.warnings ?? []; + warnings.push(warning); + const doc = sourceDoc({ + output: { + ...output, + warnings, + }, + status: JOB_STATUS.WARNINGS, + } as ReportSource); + + let body: UpdateResponse; + try { + const client = await this.getClient(); + body = await client.update(esDocForUpdate(report, doc)); + } catch (err) { + this.logError(`Error in updating status to warning! Report: ${jobDebugMessage(report)}`, err, report); // prettier-ignore + throw err; + } + + return body; + } } diff --git a/x-pack/platform/plugins/private/reporting/server/lib/tasks/index.ts b/x-pack/platform/plugins/private/reporting/server/lib/tasks/index.ts index 841d499da105..e3da53487caa 100644 --- a/x-pack/platform/plugins/private/reporting/server/lib/tasks/index.ts +++ b/x-pack/platform/plugins/private/reporting/server/lib/tasks/index.ts @@ -5,14 +5,16 @@ * 2.0. */ -import { TaskRunCreatorFunction } from '@kbn/task-manager-plugin/server'; +import { RruleSchedule, TaskRegisterDefinition } from '@kbn/task-manager-plugin/server'; import { BasePayload, ReportSource } from '@kbn/reporting-common/types'; export const REPORTING_EXECUTE_TYPE = 'report:execute'; +export const SCHEDULED_REPORTING_EXECUTE_TYPE = 'report:execute-scheduled'; export const TIME_BETWEEN_ATTEMPTS = 10 * 1000; // 10 seconds -export { ExecuteReportTask } from './execute_report'; +export { RunSingleReportTask } from './run_single_report'; +export { RunScheduledReportTask } from './run_scheduled_report'; export interface ReportTaskParams { id: string; @@ -25,18 +27,21 @@ export interface ReportTaskParams { meta: ReportSource['meta']; } +export interface ScheduledReportTaskParams { + id: string; + jobtype: ReportSource['jobtype']; + spaceId: string; + schedule: RruleSchedule; +} + +export type ScheduledReportTaskParamsWithoutSpaceId = Omit; + export enum ReportingTaskStatus { UNINITIALIZED = 'uninitialized', INITIALIZED = 'initialized', } export interface ReportingTask { - getTaskDefinition: () => { - type: string; - title: string; - createTaskRunner: TaskRunCreatorFunction; - maxAttempts: number; - timeout: string; - }; + getTaskDefinition: () => TaskRegisterDefinition; getStatus: () => ReportingTaskStatus; } diff --git a/x-pack/platform/plugins/private/reporting/server/lib/tasks/execute_report.ts b/x-pack/platform/plugins/private/reporting/server/lib/tasks/run_report.ts similarity index 59% rename from x-pack/platform/plugins/private/reporting/server/lib/tasks/execute_report.ts rename to x-pack/platform/plugins/private/reporting/server/lib/tasks/run_report.ts index 8b99f2e9514f..aa4d7fd8eae4 100644 --- a/x-pack/platform/plugins/private/reporting/server/lib/tasks/execute_report.ts +++ b/x-pack/platform/plugins/private/reporting/server/lib/tasks/run_report.ts @@ -11,11 +11,11 @@ import { timeout } from 'rxjs'; import { Writable } from 'stream'; import type { FakeRawRequest, Headers } from '@kbn/core-http-server'; import { UpdateResponse } from '@elastic/elasticsearch/lib/api/types'; -import type { KibanaRequest, Logger } from '@kbn/core/server'; +import type { KibanaRequest, Logger, SavedObject } from '@kbn/core/server'; import { CancellationToken, KibanaShuttingDownError, - QueueTimeoutError, + MissingAuthenticationError, ReportingError, durationToNumber, numberToDuration, @@ -28,34 +28,28 @@ import type { TaskRunResult, } from '@kbn/reporting-common/types'; import { decryptJobHeaders, type ReportingConfigType } from '@kbn/reporting-server'; -import type { - RunContext, - TaskManagerStartContract, - TaskRunCreatorFunction, +import { + throwRetryableError, + type ConcreteTaskInstance, + type RunContext, + type TaskManagerStartContract, + type TaskRegisterDefinition, + type TaskRunCreatorFunction, } from '@kbn/task-manager-plugin/server'; -import { throwRetryableError } from '@kbn/task-manager-plugin/server'; import { ExportTypesRegistry } from '@kbn/reporting-server/export_types_registry'; import { kibanaRequestFactory } from '@kbn/core-http-server-utils'; import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common'; -import { - REPORTING_EXECUTE_TYPE, - ReportTaskParams, - ReportingTask, - ReportingTaskStatus, - TIME_BETWEEN_ATTEMPTS, -} from '.'; -import { getContentStream, finishedWithNoPendingCallbacks } from '../content_stream'; +import { mapToReportingError } from '../../../common/errors/map_to_reporting_error'; +import { ReportTaskParams, ReportingTask, ReportingTaskStatus, TIME_BETWEEN_ATTEMPTS } from '.'; import type { ReportingCore } from '../..'; -import { - isExecutionError, - mapToReportingError, -} from '../../../common/errors/map_to_reporting_error'; import { EventTracker } from '../../usage'; -import type { ReportingStore } from '../store'; import { Report, SavedReport } from '../store'; -import type { ReportFailedFields, ReportProcessingFields } from '../store/store'; +import type { ReportFailedFields, ReportWarningFields } from '../store/store'; import { errorLogger } from './error_logger'; +import { finishedWithNoPendingCallbacks, getContentStream } from '../content_stream'; +import { EmailNotificationService } from '../../services/notifications/email_notification_service'; +import { ScheduledReportType } from '../../types'; type CompletedReportOutput = Omit; @@ -72,12 +66,6 @@ interface GetHeadersOpts { requestFromTask?: KibanaRequest; spaceId: string | undefined; } -interface ReportingExecuteTaskInstance { - state: object; - taskType: string; - params: ReportTaskParams; - runAt?: Date; -} function isOutput(output: CompletedReportOutput | Error): output is CompletedReportOutput { return (output as CompletedReportOutput).size != null; @@ -95,63 +83,100 @@ function parseError(error: unknown): ExecutionError | unknown { return error; } -export class ExecuteReportTask implements ReportingTask { - public TYPE = REPORTING_EXECUTE_TYPE; +export interface ConstructorOpts { + config: ReportingConfigType; + logger: Logger; + reporting: ReportingCore; +} - private logger: Logger; - private taskManagerStart?: TaskManagerStartContract; - private kibanaId?: string; - private kibanaName?: string; - private exportTypesRegistry: ExportTypesRegistry; - private store?: ReportingStore; - private eventTracker?: EventTracker; +export interface PrepareJobResults { + isLastAttempt: boolean; + jobId: string; + report?: SavedReport; + task?: ReportTaskParams; + scheduledReport?: SavedObject; +} - constructor( - private reporting: ReportingCore, - private config: ReportingConfigType, - logger: Logger - ) { - this.logger = logger.get('runTask'); - this.exportTypesRegistry = this.reporting.getExportTypesRegistry(); +type ReportTaskParamsType = Record; + +export abstract class RunReportTask + implements ReportingTask +{ + protected readonly logger: Logger; + protected readonly queueTimeout: number; + + protected taskManagerStart?: TaskManagerStartContract; + protected kibanaId?: string; + protected kibanaName?: string; + protected exportTypesRegistry: ExportTypesRegistry; + protected eventTracker?: EventTracker; + protected emailNotificationService?: EmailNotificationService; + + constructor(protected readonly opts: ConstructorOpts) { + this.logger = opts.logger.get('runTask'); + this.exportTypesRegistry = opts.reporting.getExportTypesRegistry(); + this.queueTimeout = durationToNumber(opts.config.queue.timeout); } - /* - * To be called from plugin start - */ - public async init(taskManager: TaskManagerStartContract) { + // Abstract methods + public abstract get TYPE(): string; + + public abstract getTaskDefinition(): TaskRegisterDefinition; + + public abstract scheduleTask( + request: KibanaRequest, + params: TaskParams + ): Promise; + + protected abstract prepareJob(taskInstance: ConcreteTaskInstance): Promise; + + protected abstract getMaxAttempts(): number | undefined; + + protected abstract notify( + report: SavedReport, + taskInstance: ConcreteTaskInstance, + output: TaskRunResult, + byteSize: number, + scheduledReport?: SavedObject, + spaceId?: string + ): Promise; + + // Public methods + public async init( + taskManager: TaskManagerStartContract, + emailNotificationService?: EmailNotificationService + ) { this.taskManagerStart = taskManager; - const { reporting } = this; - const { uuid, name } = reporting.getServerInfo(); + const { uuid, name } = this.opts.reporting.getServerInfo(); this.kibanaId = uuid; this.kibanaName = name; + + this.emailNotificationService = emailNotificationService; } - /* - * Async get the ReportingStore: it is only available after PluginStart - */ - private async getStore(): Promise { - if (this.store) { - return this.store; + public getStatus() { + if (this.taskManagerStart) { + return ReportingTaskStatus.INITIALIZED; } - const { store } = await this.reporting.getPluginStartDeps(); - this.store = store; - return store; + + return ReportingTaskStatus.UNINITIALIZED; } - private getTaskManagerStart() { + // Protected methods + protected getTaskManagerStart() { if (!this.taskManagerStart) { throw new Error('Reporting task runner has not been initialized!'); } return this.taskManagerStart; } - private getEventTracker(report: Report) { + protected getEventTracker(report: Report) { if (this.eventTracker) { return this.eventTracker; } - const eventTracker = this.reporting.getEventTracker( + const eventTracker = this.opts.reporting.getEventTracker( report._id, report.jobtype, report.payload.objectType @@ -160,91 +185,26 @@ export class ExecuteReportTask implements ReportingTask { return this.eventTracker; } - private getJobContentEncoding(jobType: string) { + protected getJobContentEncoding(jobType: string) { const exportType = this.exportTypesRegistry.getByJobType(jobType); return exportType.jobContentEncoding; } - private async _claimJob(task: ReportTaskParams): Promise { - if (this.kibanaId == null) { - throw new Error(`Kibana instance ID is undefined!`); - } - if (this.kibanaName == null) { - throw new Error(`Kibana instance name is undefined!`); - } - - const store = await this.getStore(); - const report = await store.findReportFromTask(task); // receives seq_no and primary_term - const logger = this.logger.get(report._id); - - if (report.status === 'completed') { - throw new Error(`Can not claim the report job: it is already completed!`); - } - - const m = moment(); - - // check if job has exceeded the configured maxAttempts - const maxAttempts = this.getMaxAttempts(); - if (report.attempts >= maxAttempts) { - let err: ReportingError; - if (report.error && isExecutionError(report.error)) { - // We have an error stored from a previous attempts, so we'll use that - // error to fail the job and return it to the user. - const { error } = report; - err = mapToReportingError(error); - err.stack = error.stack; - } else { - if (report.error && report.error instanceof Error) { - errorLogger(logger, 'Error executing report', report.error); - } - err = new QueueTimeoutError( - `Max attempts reached (${maxAttempts}). Queue timeout reached.` - ); - } - await this._failJob(report, err); - throw err; - } - - const queueTimeout = durationToNumber(this.config.queue.timeout); - const startTime = m.toISOString(); - const expirationTime = m.add(queueTimeout).toISOString(); - - const doc: ReportProcessingFields = { - kibana_id: this.kibanaId, - kibana_name: this.kibanaName, - attempts: report.attempts + 1, - max_attempts: maxAttempts, - started_at: startTime, - timeout: queueTimeout, - process_expiration: expirationTime, - }; - - const claimedReport = new SavedReport({ - ...report, - ...doc, - }); - - logger.info( - `Claiming ${claimedReport.jobtype} ${report._id} ` + - `[_index: ${report._index}] ` + - `[_seq_no: ${report._seq_no}] ` + - `[_primary_term: ${report._primary_term}] ` + - `[attempts: ${report.attempts}] ` + - `[process_expiration: ${expirationTime}]` - ); - - // event tracking of claimed job - const eventTracker = this.getEventTracker(report); - const timeSinceCreation = Date.now() - new Date(report.created_at).valueOf(); - eventTracker?.claimJob({ timeSinceCreation }); - - const resp = await store.setReportClaimed(claimedReport, doc); - claimedReport._seq_no = resp._seq_no!; - claimedReport._primary_term = resp._primary_term!; - return claimedReport; + protected getJobContentExtension(jobType: string) { + const exportType = this.exportTypesRegistry.getByJobType(jobType); + return exportType.jobContentExtension; } - private async _failJob( + protected getMaxConcurrency() { + return this.opts.config.queue.pollEnabled ? 1 : 0; + } + + protected getQueueTimeout() { + // round up from ms to the nearest second + return Math.ceil(numberToDuration(this.opts.config.queue.timeout).asSeconds()) + 's'; + } + + protected async failJob( report: SavedReport, error?: ReportingError ): Promise> { @@ -255,13 +215,13 @@ export class ExecuteReportTask implements ReportingTask { let docOutput; if (error) { errorLogger(logger, message, error); - docOutput = this._formatOutput(error); + docOutput = this.formatOutput(error); } else { errorLogger(logger, message); } // update the report in the store - const store = await this.getStore(); + const store = await this.opts.reporting.getStore(); const completedTime = moment(); const doc: ReportFailedFields = { completed_at: completedTime.toISOString(), @@ -280,7 +240,7 @@ export class ExecuteReportTask implements ReportingTask { return await store.setReportFailed(report, doc); } - private async _saveExecutionError( + protected async saveExecutionError( report: SavedReport, failedToExecuteErr: any ): Promise> { @@ -291,7 +251,7 @@ export class ExecuteReportTask implements ReportingTask { errorLogger(logger, message, failedToExecuteErr); // update the report in the store - const store = await this.getStore(); + const store = await this.opts.reporting.getStore(); const doc: ReportFailedFields = { output: null, error: errorParsed, @@ -300,7 +260,25 @@ export class ExecuteReportTask implements ReportingTask { return await store.setReportError(report, doc); } - private _formatOutput(output: CompletedReportOutput | ReportingError): ReportOutput { + protected async saveExecutionWarning( + report: SavedReport, + output: CompletedReportOutput, + message: string + ): Promise> { + const logger = this.logger.get(report._id); + logger.warn(message); + + // update the report in the store + const store = await this.opts.reporting.getStore(); + const doc: ReportWarningFields = { + output, + warning: message, + }; + + return await store.setReportWarning(report, doc); + } + + protected formatOutput(output: CompletedReportOutput | ReportingError): ReportOutput { const docOutput = {} as ReportOutput; const unknownMime = null; @@ -324,7 +302,7 @@ export class ExecuteReportTask implements ReportingTask { return docOutput; } - private async _getRequestToUse({ + protected async getRequestToUse({ requestFromTask, spaceId, encryptedHeaders, @@ -339,17 +317,17 @@ export class ExecuteReportTask implements ReportingTask { } let decryptedHeaders; - if (this.config.encryptionKey && encryptedHeaders) { + if (this.opts.config.encryptionKey && encryptedHeaders) { // get decrypted headers decryptedHeaders = await decryptJobHeaders( - this.config.encryptionKey, + this.opts.config.encryptionKey, encryptedHeaders, this.logger ); } if (!decryptedHeaders && !apiKeyAuthHeaders) { - throw new Error('No headers found to execute report'); + throw new MissingAuthenticationError(); } let headersToUse: Headers = {}; @@ -367,10 +345,10 @@ export class ExecuteReportTask implements ReportingTask { headersToUse = decryptedHeaders || {}; } - return this._getFakeRequest(headersToUse, spaceId, this.logger); + return this.getFakeRequest(headersToUse, spaceId, this.logger); } - private _getFakeRequest( + protected getFakeRequest( headers: Headers, spaceId: string | undefined, logger = this.logger @@ -381,7 +359,7 @@ export class ExecuteReportTask implements ReportingTask { }; const fakeRequest = kibanaRequestFactory(rawRequest); - const setupDeps = this.reporting.getPluginSetupDeps(); + const setupDeps = this.opts.reporting.getPluginSetupDeps(); const spacesService = setupDeps.spaces?.spacesService; if (spacesService) { if (spaceId && spaceId !== DEFAULT_SPACE_ID) { @@ -392,7 +370,7 @@ export class ExecuteReportTask implements ReportingTask { return fakeRequest; } - private async _performJob({ + protected async performJob({ task, fakeRequest, taskInstanceFields, @@ -405,8 +383,7 @@ export class ExecuteReportTask implements ReportingTask { } // run the report // if workerFn doesn't finish before timeout, call the cancellationToken and throw an error - const queueTimeout = durationToNumber(this.config.queue.timeout); - const request = await this._getRequestToUse({ + const request = await this.getRequestToUse({ requestFromTask: fakeRequest, spaceId: task.payload.spaceId, encryptedHeaders: task.payload.headers, @@ -422,11 +399,11 @@ export class ExecuteReportTask implements ReportingTask { cancellationToken, stream, }) - ).pipe(timeout(queueTimeout)) // throw an error if a value is not emitted before timeout + ).pipe(timeout(this.queueTimeout)) // throw an error if a value is not emitted before timeout ); } - private async _completeJob( + protected async completeJob( report: SavedReport, output: CompletedReportOutput ): Promise { @@ -436,8 +413,8 @@ export class ExecuteReportTask implements ReportingTask { logger.debug(`Saving ${report.jobtype} to ${docId}.`); const completedTime = moment(); - const docOutput = this._formatOutput(output); - const store = await this.getStore(); + const docOutput = this.formatOutput(output); + const store = await this.opts.reporting.getStore(); const doc = { completed_at: completedTime.toISOString(), metrics: output.metrics, @@ -477,25 +454,20 @@ export class ExecuteReportTask implements ReportingTask { } // Generic is used to let TS infer the return type at call site. - private async throwIfKibanaShutsDown(): Promise { - await Rx.firstValueFrom(this.reporting.getKibanaShutdown$()); + protected async throwIfKibanaShutsDown(): Promise { + await Rx.firstValueFrom(this.opts.reporting.getKibanaShutdown$()); throw new KibanaShuttingDownError(); } /* * Provides a TaskRunner for Task Manager */ - private getTaskRunner(): TaskRunCreatorFunction { + protected getTaskRunner(): TaskRunCreatorFunction { // Keep a separate local stack for each task run return ({ taskInstance, fakeRequest }: RunContext) => { let jobId: string; const cancellationToken = new CancellationToken(); - const { - attempts: taskAttempts, - params: reportTaskParams, - retryAt: taskRetryAt, - startedAt: taskStartedAt, - } = taskInstance; + const { retryAt: taskRetryAt, startedAt: taskStartedAt } = taskInstance; return { /* @@ -506,31 +478,30 @@ export class ExecuteReportTask implements ReportingTask { * If any error happens, additional retry attempts may be picked up by a separate instance */ run: async () => { - let report: SavedReport | undefined; - const isLastAttempt = taskAttempts >= this.getMaxAttempts(); - - // find the job in the store and set status to processing - const task = reportTaskParams as ReportTaskParams; - jobId = task?.id; - - try { - if (!jobId) { - throw new Error('Invalid report data provided in scheduled task!'); - } - if (!isLastAttempt) { - this.reporting.trackReport(jobId); - } - - // Update job status to claimed - report = await this._claimJob(task); - } catch (failedToClaim) { - // error claiming report - log the error - // could be version conflict, or too many attempts or no longer connected to ES - errorLogger(this.logger, `Error in claiming ${jobId}`, failedToClaim); + if (this.kibanaId == null) { + throw new Error(`Kibana instance ID is undefined!`); + } + if (this.kibanaName == null) { + throw new Error(`Kibana instance name is undefined!`); } - if (!report) { - this.reporting.untrackReport(jobId); + let report: SavedReport | undefined; + const { + isLastAttempt, + jobId: jId, + report: preparedReport, + task, + scheduledReport, + } = await this.prepareJob(taskInstance); + jobId = jId; + report = preparedReport; + + if (!isLastAttempt) { + this.opts.reporting.trackReport(jobId); + } + + if (!report || !task) { + this.opts.reporting.untrackReport(jobId); if (isLastAttempt) { errorLogger(this.logger, `Job ${jobId} failed too many times. Exiting...`); @@ -545,22 +516,27 @@ export class ExecuteReportTask implements ReportingTask { } const { jobtype: jobType, attempts } = report; - const maxAttempts = this.getMaxAttempts(); const logger = this.logger.get(jobId); - logger.debug( - `Starting ${jobType} report ${jobId}: attempt ${attempts} of ${maxAttempts}.` - ); - logger.debug(`Reports running: ${this.reporting.countConcurrentReports()}.`); + const maxAttempts = this.getMaxAttempts(); + if (maxAttempts) { + logger.debug( + `Starting ${jobType} report ${jobId}: attempt ${attempts} of ${maxAttempts}.` + ); + } else { + logger.debug(`Starting ${jobType} report ${jobId}.`); + } - const eventLog = this.reporting.getEventLogger( + logger.debug(`Reports running: ${this.opts.reporting.countConcurrentReports()}.`); + + const eventLog = this.opts.reporting.getEventLogger( new Report({ ...task, _id: task.id, _index: task.index }) ); try { const jobContentEncoding = this.getJobContentEncoding(jobType); const stream = await getContentStream( - this.reporting, + this.opts.reporting, { id: report._id, index: report._index, @@ -574,7 +550,7 @@ export class ExecuteReportTask implements ReportingTask { eventLog.logExecutionStart(); const output = await Promise.race([ - this._performJob({ + this.performJob({ task, fakeRequest, taskInstanceFields: { retryAt: taskRetryAt, startedAt: taskStartedAt }, @@ -593,18 +569,28 @@ export class ExecuteReportTask implements ReportingTask { report._seq_no = stream.getSeqNo()!; report._primary_term = stream.getPrimaryTerm()!; + const byteSize = stream.bytesWritten; eventLog.logExecutionComplete({ ...(output.metrics ?? {}), - byteSize: stream.bytesWritten, + byteSize, }); if (output) { - logger.debug(`Job output size: ${stream.bytesWritten} bytes.`); + logger.debug(`Job output size: ${byteSize} bytes.`); // Update the job status to "completed" - report = await this._completeJob(report, { + report = await this.completeJob(report, { ...output, - size: stream.bytesWritten, + size: byteSize, }); + + await this.notify( + report, + taskInstance, + output, + byteSize, + scheduledReport, + task.payload.spaceId + ); } // untrack the report for concurrency awareness @@ -612,11 +598,9 @@ export class ExecuteReportTask implements ReportingTask { } catch (failedToExecuteErr) { eventLog.logError(failedToExecuteErr); - await this._saveExecutionError(report, failedToExecuteErr).catch( - (failedToSaveError) => { - errorLogger(logger, `Error in saving execution error ${jobId}`, failedToSaveError); - } - ); + await this.saveExecutionError(report, failedToExecuteErr).catch((failedToSaveError) => { + errorLogger(logger, `Error in saving execution error ${jobId}`, failedToSaveError); + }); cancellationToken.cancel(); @@ -624,8 +608,8 @@ export class ExecuteReportTask implements ReportingTask { throwRetryableError(error, new Date(Date.now() + TIME_BETWEEN_ATTEMPTS)); } finally { - this.reporting.untrackReport(jobId); - logger.debug(`Reports running: ${this.reporting.countConcurrentReports()}.`); + this.opts.reporting.untrackReport(jobId); + logger.debug(`Reports running: ${this.opts.reporting.countConcurrentReports()}.`); } }, @@ -642,47 +626,4 @@ export class ExecuteReportTask implements ReportingTask { }; }; } - - private getMaxAttempts() { - return this.config.capture.maxAttempts ?? 1; - } - - public getTaskDefinition() { - // round up from ms to the nearest second - const queueTimeout = Math.ceil(numberToDuration(this.config.queue.timeout).asSeconds()) + 's'; - const maxConcurrency = this.config.queue.pollEnabled ? 1 : 0; - const maxAttempts = this.getMaxAttempts(); - - return { - type: REPORTING_EXECUTE_TYPE, - title: 'Reporting: execute job', - createTaskRunner: this.getTaskRunner(), - maxAttempts: maxAttempts + 1, // Add 1 so we get an extra attempt in case of failure during a Kibana restart - timeout: queueTimeout, - maxConcurrency, - }; - } - - public async scheduleTask(request: KibanaRequest, params: ReportTaskParams) { - const reportingHealth = await this.reporting.getHealthInfo(); - const shouldScheduleWithApiKey = - reportingHealth.hasPermanentEncryptionKey && reportingHealth.isSufficientlySecure; - const taskInstance: ReportingExecuteTaskInstance = { - taskType: REPORTING_EXECUTE_TYPE, - state: {}, - params, - }; - - return shouldScheduleWithApiKey - ? await this.getTaskManagerStart().schedule(taskInstance, { request }) - : await this.getTaskManagerStart().schedule(taskInstance); - } - - public getStatus() { - if (this.taskManagerStart) { - return ReportingTaskStatus.INITIALIZED; - } - - return ReportingTaskStatus.UNINITIALIZED; - } } diff --git a/x-pack/platform/plugins/private/reporting/server/lib/tasks/run_scheduled_report.test.ts b/x-pack/platform/plugins/private/reporting/server/lib/tasks/run_scheduled_report.test.ts new file mode 100644 index 000000000000..f1b2f70dde60 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/lib/tasks/run_scheduled_report.test.ts @@ -0,0 +1,709 @@ +/* + * 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 { Transform } from 'stream'; +import type { estypes } from '@elastic/elasticsearch'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { MockedLogger } from '@kbn/logging-mocks'; +import { JOB_STATUS, KibanaShuttingDownError } from '@kbn/reporting-common'; +import { ReportDocument } from '@kbn/reporting-common/types'; +import { createMockConfigSchema } from '@kbn/reporting-mocks-server'; +import { type ExportType, type ReportingConfigType } from '@kbn/reporting-server'; +import type { RunContext } from '@kbn/task-manager-plugin/server'; +import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; +import { notificationsMock } from '@kbn/notifications-plugin/server/mocks'; + +import { RunScheduledReportTask, SCHEDULED_REPORTING_EXECUTE_TYPE } from '.'; +import { ReportingCore } from '../..'; +import { createMockReportingCore } from '../../test_helpers'; +import { + FakeRawRequest, + KibanaRequest, + SavedObject, + SavedObjectsClientContract, +} from '@kbn/core/server'; +import { Frequency } from '@kbn/rrule'; +import { ReportingStore, SavedReport } from '../store'; +import { ScheduledReportType } from '../../types'; +import { EmailNotificationService } from '../../services/notifications/email_notification_service'; + +interface StreamMock { + getSeqNo: () => number; + getPrimaryTerm: () => number; + write: (data: string) => void; + fail: () => void; + end: () => void; + transform: Transform; +} + +function createStreamMock(): StreamMock { + const transform: Transform = new Transform({}); + + return { + getSeqNo: () => 10, + getPrimaryTerm: () => 20, + write: (data: string) => { + transform.push(`${data}\n`); + }, + fail: () => { + transform.emit('error', new Error('Stream failed')); + transform.end(); + }, + transform, + end: () => { + transform.end(); + }, + }; +} + +const mockStream = createStreamMock(); +jest.mock('../content_stream', () => ({ + getContentStream: () => mockStream, + finishedWithNoPendingCallbacks: () => Promise.resolve(), +})); + +jest.mock('../../services/notifications/email_notification_service'); + +const fakeRawRequest: FakeRawRequest = { + headers: { + authorization: `ApiKey skdjtq4u543yt3rhewrh`, + }, + path: '/', +}; + +const payload = { + headers: '', + title: 'Test Report', + browserTimezone: '', + objectType: 'test', + version: '8.0.0', +}; + +const scheduledReport: SavedObject = { + id: 'report-so-id', + attributes: { + createdAt: new Date().toISOString(), + createdBy: 'test-user', + enabled: true, + jobType: 'test1', + meta: { objectType: 'test' }, + migrationVersion: '8.0.0', + payload: JSON.stringify(payload), + schedule: { rrule: { freq: Frequency.DAILY, interval: 2, tzid: 'UTC' } }, + title: 'Test Report', + notification: { + email: { + to: ['test1@test.com'], + bcc: ['test2@test.com'], + }, + }, + }, + references: [], + type: 'scheduled-report', +}; + +const savedReport = new SavedReport({ + _id: '290357209345723095', + _index: '.reporting-fantastic', + _seq_no: 23, + _primary_term: 354000, + jobtype: 'test1', + migration_version: '8.0.0', + payload, + created_at: new Date().toISOString(), + created_by: 'test-user', + meta: { objectType: 'test' }, + scheduled_report_id: 'report-so-id', + status: JOB_STATUS.PROCESSING, +}); + +describe('Run Scheduled Report Task', () => { + let mockReporting: ReportingCore; + let configType: ReportingConfigType; + let soClient: SavedObjectsClientContract; + let reportStore: ReportingStore; + const notifications = notificationsMock.createStart(); + let emailNotificationService: EmailNotificationService; + let logger: MockedLogger; + + const runTaskFn = jest.fn().mockResolvedValue({ content_type: 'application/pdf' }); + beforeAll(async () => { + configType = createMockConfigSchema(); + mockReporting = await createMockReportingCore(configType); + + soClient = await mockReporting.getInternalSoClient(); + + mockReporting.getExportTypesRegistry().register({ + id: 'test1', + name: 'Test1', + setup: jest.fn(), + start: jest.fn(), + createJob: () => new Promise(() => {}), + runTask: runTaskFn, + jobContentEncoding: 'base64', + jobContentExtension: 'pdf', + jobType: 'test1', + validLicenses: [], + } as unknown as ExportType); + }); + + beforeEach(async () => { + logger = loggingSystemMock.createLogger(); + soClient.get = jest.fn().mockImplementation(async () => { + return scheduledReport; + }); + reportStore = await mockReporting.getStore(); + reportStore.addReport = jest.fn().mockImplementation(async () => { + return savedReport; + }); + reportStore.setReportError = jest.fn(() => + Promise.resolve({ + _id: 'test', + jobtype: 'noop', + status: 'processing', + } as unknown as estypes.UpdateUpdateWriteResponseBase) + ); + reportStore.setReportWarning = jest.fn(); + emailNotificationService = new EmailNotificationService({ + notifications, + }); + }); + + it('Instance setup', () => { + const task = new RunScheduledReportTask({ + reporting: mockReporting, + config: configType, + logger, + }); + expect(task.getStatus()).toBe('uninitialized'); + expect(task.getTaskDefinition()).toMatchInlineSnapshot(` + Object { + "createTaskRunner": [Function], + "maxConcurrency": 1, + "timeout": "120s", + "title": "Reporting: execute scheduled job", + "type": "report:execute-scheduled", + } + `); + }); + + it('Instance start', () => { + const mockTaskManager = taskManagerMock.createStart(); + const task = new RunScheduledReportTask({ + reporting: mockReporting, + config: configType, + logger, + }); + expect(task.init(mockTaskManager, emailNotificationService)); + expect(task.getStatus()).toBe('initialized'); + }); + + it('create task runner', async () => { + logger.info = jest.fn(); + logger.error = jest.fn(); + + const task = new RunScheduledReportTask({ + reporting: mockReporting, + config: configType, + logger, + }); + const taskDef = task.getTaskDefinition(); + const taskRunner = taskDef.createTaskRunner({ + taskInstance: { + id: 'random-task-id', + params: { id: 'cool-reporting-id', jobtype: 'test1' }, + }, + } as unknown as RunContext); + expect(taskRunner).toHaveProperty('run'); + expect(taskRunner).toHaveProperty('cancel'); + }); + + it('Max Concurrency is 0 if pollEnabled is false', () => { + const queueConfig = { + queue: { pollEnabled: false, timeout: 55000 }, + } as unknown as ReportingConfigType['queue']; + + const task = new RunScheduledReportTask({ + reporting: mockReporting, + config: { ...configType, ...queueConfig }, + logger, + }); + expect(task.getStatus()).toBe('uninitialized'); + expect(task.getTaskDefinition()).toMatchInlineSnapshot(` + Object { + "createTaskRunner": [Function], + "maxConcurrency": 0, + "timeout": "55s", + "title": "Reporting: execute scheduled job", + "type": "report:execute-scheduled", + } + `); + }); + + it('schedules task with request', async () => { + const task = new RunScheduledReportTask({ + reporting: mockReporting, + config: configType, + logger, + }); + const mockTaskManager = taskManagerMock.createStart(); + await task.init(mockTaskManager, emailNotificationService); + + await task.scheduleTask(fakeRawRequest as unknown as KibanaRequest, { + id: 'report-so-id', + jobtype: 'test1', + schedule: { + rrule: { freq: Frequency.DAILY, interval: 2, tzid: 'UTC' }, + } as never, + }); + + expect(mockTaskManager.schedule).toHaveBeenCalledWith( + { + id: 'report-so-id', + taskType: SCHEDULED_REPORTING_EXECUTE_TYPE, + state: {}, + params: { + id: 'report-so-id', + spaceId: 'default', + jobtype: 'test1', + }, + schedule: { + rrule: { freq: Frequency.DAILY, interval: 2, tzid: 'UTC' }, + }, + }, + { request: fakeRawRequest } + ); + }); + + it('uses authorization headers from task manager fake request', async () => { + const runAt = new Date('2023-10-01T00:00:00Z'); + const task = new RunScheduledReportTask({ + reporting: mockReporting, + config: configType, + logger, + }); + + jest + // @ts-expect-error TS compilation fails: this overrides a private method of the RunScheduledReportTask instance + .spyOn(task, 'completeJob') + .mockResolvedValueOnce({ _id: 'test', jobtype: 'test1', status: 'pending' } as never); + const mockTaskManager = taskManagerMock.createStart(); + await task.init(mockTaskManager, emailNotificationService); + + const taskDef = task.getTaskDefinition(); + const taskRunner = taskDef.createTaskRunner({ + taskInstance: { + id: 'report-so-id', + runAt, + params: { + id: 'report-so-id', + jobtype: 'test1', + schedule: { + rrule: { freq: Frequency.DAILY, interval: 2, tzid: 'UTC' }, + }, + }, + }, + fakeRequest: fakeRawRequest, + } as unknown as RunContext); + + await taskRunner.run(); + + expect(soClient.get).toHaveBeenCalledWith('scheduled_report', 'report-so-id', { + namespace: 'default', + }); + expect(reportStore.addReport).toHaveBeenCalledWith( + expect.objectContaining({ + _id: expect.any(String), + _index: '.kibana-reporting', + jobtype: 'test1', + created_at: expect.any(String), + created_by: 'test-user', + payload: { + headers: '', + title: expect.any(String), + browserTimezone: '', + objectType: 'test', + version: '8.0.0', + forceNow: expect.any(String), + }, + meta: { objectType: 'test' }, + status: 'processing', + attempts: 1, + scheduled_report_id: 'report-so-id', + kibana_name: 'kibana', + kibana_id: 'instance-uuid', + started_at: expect.any(String), + timeout: 120000, + max_attempts: 1, + process_expiration: expect.any(String), + migration_version: '7.14.0', + }) + ); + expect(runTaskFn.mock.calls[0][0].request.headers).toEqual({ + authorization: 'ApiKey skdjtq4u543yt3rhewrh', + }); + }); + + it('throws if no fake request from task', async () => { + const task = new RunScheduledReportTask({ + reporting: mockReporting, + config: configType, + logger, + }); + + const mockTaskManager = taskManagerMock.createStart(); + await task.init(mockTaskManager, emailNotificationService); + + const taskDef = task.getTaskDefinition(); + const taskRunner = taskDef.createTaskRunner({ + taskInstance: { + id: 'report-so-id', + runAt: new Date('2023-10-01T00:00:00Z'), + params: { + id: 'report-so-id', + jobtype: 'test1', + schedule: { + rrule: { freq: Frequency.DAILY, interval: 2, tzid: 'UTC' }, + }, + }, + }, + fakeRequest: undefined, + } as unknown as RunContext); + + await expect(taskRunner.run()).rejects.toThrowErrorMatchingInlineSnapshot( + `"ReportingError(code: missing_authentication_header_error)"` + ); + + expect(soClient.get).toHaveBeenCalled(); + expect(reportStore.addReport).toHaveBeenCalled(); + + expect(reportStore.setReportError).toHaveBeenLastCalledWith( + expect.objectContaining({ + _id: '290357209345723095', + }), + expect.objectContaining({ + error: expect.objectContaining({ + message: `ReportingError(code: missing_authentication_header_error)`, + }), + }) + ); + }); + + it('throws during reporting if Kibana starts shutting down', async () => { + mockReporting.getExportTypesRegistry().register({ + id: 'noop', + name: 'Noop', + setup: jest.fn(), + start: jest.fn(), + createJob: () => new Promise(() => {}), + runTask: () => new Promise(() => {}), + jobContentExtension: 'pdf', + jobType: 'noop', + validLicenses: [], + } as unknown as ExportType); + const task = new RunScheduledReportTask({ + reporting: mockReporting, + config: configType, + logger, + }); + + jest + // @ts-expect-error TS compilation fails: this overrides a private method of the RunScheduledReportTask instance + .spyOn(task, 'prepareJob') + .mockResolvedValueOnce({ + isLastAttempt: false, + jobId: '290357209345723095', + report: { _id: '290357209345723095', jobtype: 'noop' }, + task: { + id: '290357209345723095', + index: '.reporting-fantastic', + jobtype: 'noop', + payload, + }, + } as never); + + const mockTaskManager = taskManagerMock.createStart(); + await task.init(mockTaskManager, emailNotificationService); + + const taskDef = task.getTaskDefinition(); + const taskRunner = taskDef.createTaskRunner({ + taskInstance: { + id: 'report-so-id', + params: { + id: 'report-so-id', + jobtype: 'test1', + schedule: { + rrule: { freq: Frequency.DAILY, interval: 2, tzid: 'UTC' }, + }, + }, + }, + fakeRequest: fakeRawRequest, + } as unknown as RunContext); + + const taskPromise = taskRunner.run(); + setImmediate(() => { + mockReporting.pluginStop(); + }); + await taskPromise.catch(() => {}); + + expect(reportStore.setReportError).toHaveBeenLastCalledWith( + expect.objectContaining({ + _id: '290357209345723095', + }), + expect.objectContaining({ + error: expect.objectContaining({ + message: `ReportingError(code: ${new KibanaShuttingDownError().code})`, + }), + }) + ); + }); + + describe('notify', () => { + it('sends an email notification', async () => { + const task = new RunScheduledReportTask({ + reporting: mockReporting, + config: configType, + logger, + }); + const mockTaskManager = taskManagerMock.createStart(); + await task.init(mockTaskManager, emailNotificationService); + const taskInstance = { + id: 'task-id', + runAt: new Date('2025-06-04T00:00:00Z'), + params: { id: 'report-so-id', jobtype: 'test1' }, + }; + const byteSize = 2097152; // 2MB + const output = { content_type: 'application/pdf' }; + + // @ts-expect-error + await task.notify(savedReport, taskInstance, output, byteSize, scheduledReport, 'default'); + expect(soClient.get).not.toHaveBeenCalled(); + expect(emailNotificationService.notify).toHaveBeenCalledWith({ + contentType: 'application/pdf', + emailParams: { + bcc: ['test2@test.com'], + cc: undefined, + spaceId: 'default', + subject: 'Test Report-2025-06-04T00:00:00.000Z scheduled report', + to: ['test1@test.com'], + }, + filename: 'Test Report-2025-06-04T00:00:00.000Z.pdf', + id: '290357209345723095', + index: '.reporting-fantastic', + relatedObject: { + id: 'report-so-id', + namespace: 'default', + type: 'scheduled-report', + }, + reporting: mockReporting, + }); + }); + + it("gets the scheduled_report saved object if it's not defined", async () => { + const task = new RunScheduledReportTask({ + reporting: mockReporting, + config: configType, + logger, + }); + const mockTaskManager = taskManagerMock.createStart(); + await task.init(mockTaskManager, emailNotificationService); + const taskInstance = { + id: 'task-id', + runAt: new Date('2025-06-04T00:00:00Z'), + params: { id: 'report-so-id', jobtype: 'test1' }, + }; + const byteSize = 2097152; // 2MB + const output = { content_type: 'application/pdf' }; + + // @ts-expect-error + await task.notify(savedReport, taskInstance, output, byteSize, undefined, 'default'); + expect(soClient.get).toHaveBeenCalled(); + expect(emailNotificationService.notify).toHaveBeenCalledWith({ + contentType: 'application/pdf', + emailParams: { + bcc: ['test2@test.com'], + cc: undefined, + spaceId: 'default', + subject: 'Test Report-2025-06-04T00:00:00.000Z scheduled report', + to: ['test1@test.com'], + }, + filename: 'Test Report-2025-06-04T00:00:00.000Z.pdf', + id: '290357209345723095', + index: '.reporting-fantastic', + relatedObject: { + id: 'report-so-id', + namespace: 'default', + type: 'scheduled-report', + }, + reporting: mockReporting, + }); + }); + + it('does not send an email notification if the notification is not defined', async () => { + const task = new RunScheduledReportTask({ + reporting: mockReporting, + config: configType, + logger, + }); + const mockTaskManager = taskManagerMock.createStart(); + await task.init(mockTaskManager, emailNotificationService); + const taskInstance = { + id: 'task-id', + runAt: new Date('2025-06-04T00:00:00Z'), + params: { id: 'report-so-id', jobtype: 'test1' }, + }; + const byteSize = 2097152; // 2MB + const output = { content_type: 'application/pdf' }; + const noNotification = { ...scheduledReport, notification: undefined }; + + // @ts-expect-error + await task.notify(savedReport, taskInstance, output, byteSize, noNotification, 'default'); + expect(soClient.get).not.toHaveBeenCalled(); + expect(emailNotificationService.notify).not.toHaveBeenCalledWith(); + }); + + it('logs a warning and sets the execution to warning when the report is larger than 10MB', async () => { + const task = new RunScheduledReportTask({ + reporting: mockReporting, + config: configType, + logger, + }); + const mockTaskManager = taskManagerMock.createStart(); + await task.init(mockTaskManager, emailNotificationService); + const taskInstance = { + id: 'task-id', + runAt: new Date('2025-06-04T00:00:00Z'), + params: { id: 'report-so-id', jobtype: 'test1' }, + }; + const byteSize = 11534336; // 11MB + const output = { content_type: 'application/pdf' }; + + // @ts-expect-error + await task.notify(savedReport, taskInstance, output, byteSize, scheduledReport, 'default'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(emailNotificationService.notify).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + 'Error sending notification for scheduled report: The report is larger than the 10MB limit.' + ); + expect(reportStore.setReportWarning).toHaveBeenCalledWith(savedReport, { + output: { content_type: 'application/pdf', size: 11534336 }, + warning: + 'Error sending notification for scheduled report: The report is larger than the 10MB limit.', + }); + }); + + it('logs a warning and sets the execution to warning when the notification service is not initialized', async () => { + const task = new RunScheduledReportTask({ + reporting: mockReporting, + config: configType, + logger, + }); + const mockTaskManager = taskManagerMock.createStart(); + await task.init(mockTaskManager); + const taskInstance = { + id: 'task-id', + runAt: new Date('2025-06-04T00:00:00Z'), + params: { id: 'report-so-id', jobtype: 'test1' }, + }; + const byteSize = 2097152; // 2MB + const output = { content_type: 'application/pdf' }; + + // @ts-expect-error + await task.notify(savedReport, taskInstance, output, byteSize, scheduledReport, 'default'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(emailNotificationService.notify).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + 'Error sending notification for scheduled report: Reporting notification service has not been initialized.' + ); + expect(reportStore.setReportWarning).toHaveBeenCalledWith(savedReport, { + output: { content_type: 'application/pdf', size: 2097152 }, + warning: + 'Error sending notification for scheduled report: Reporting notification service has not been initialized.', + }); + }); + + it('logs a warning and sets the execution to warning if the notification service throws an error', async () => { + jest + .spyOn(emailNotificationService, 'notify') + .mockRejectedValueOnce(new Error('This is a test error!')); + const task = new RunScheduledReportTask({ + reporting: mockReporting, + config: configType, + logger, + }); + const mockTaskManager = taskManagerMock.createStart(); + await task.init(mockTaskManager, emailNotificationService); + const taskInstance = { + id: 'task-id', + runAt: new Date('2025-06-04T00:00:00Z'), + params: { id: 'report-so-id', jobtype: 'test1' }, + }; + const byteSize = 2097152; // 2MB + const output = { content_type: 'application/pdf' }; + + // @ts-expect-error + await task.notify(savedReport, taskInstance, output, byteSize, scheduledReport, 'default'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(emailNotificationService.notify).toHaveBeenCalledWith({ + contentType: 'application/pdf', + emailParams: { + bcc: ['test2@test.com'], + cc: undefined, + spaceId: 'default', + subject: 'Test Report-2025-06-04T00:00:00.000Z scheduled report', + to: ['test1@test.com'], + }, + filename: 'Test Report-2025-06-04T00:00:00.000Z.pdf', + id: '290357209345723095', + index: '.reporting-fantastic', + relatedObject: { + id: 'report-so-id', + namespace: 'default', + type: 'scheduled-report', + }, + reporting: mockReporting, + }); + expect(logger.warn).toHaveBeenCalledWith( + 'Error sending notification for scheduled report: This is a test error!' + ); + expect(reportStore.setReportWarning).toHaveBeenCalledWith(savedReport, { + output: { content_type: 'application/pdf', size: 2097152 }, + warning: 'Error sending notification for scheduled report: This is a test error!', + }); + }); + + it('logs an error if there is an error thrown setting execution to warning', async () => { + jest + .spyOn(reportStore, 'setReportWarning') + .mockRejectedValueOnce('Error setting status to warning'); + const task = new RunScheduledReportTask({ + reporting: mockReporting, + config: configType, + logger, + }); + const mockTaskManager = taskManagerMock.createStart(); + await task.init(mockTaskManager, emailNotificationService); + const taskInstance = { + id: 'task-id', + runAt: new Date('2025-06-04T00:00:00Z'), + params: { id: 'report-so-id', jobtype: 'test1' }, + }; + const byteSize = 11534336; // 11MB + const output = { content_type: 'application/pdf' }; + + // @ts-expect-error + await task.notify(savedReport, taskInstance, output, byteSize, scheduledReport, 'default'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(emailNotificationService.notify).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/platform/plugins/private/reporting/server/lib/tasks/run_scheduled_report.ts b/x-pack/platform/plugins/private/reporting/server/lib/tasks/run_scheduled_report.ts new file mode 100644 index 000000000000..9452407be134 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/lib/tasks/run_scheduled_report.ts @@ -0,0 +1,201 @@ +/* + * 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, SavedObject } from '@kbn/core/server'; +import type { TaskRunResult } from '@kbn/reporting-common/types'; +import type { ConcreteTaskInstance, TaskInstance } from '@kbn/task-manager-plugin/server'; + +import { DEFAULT_SPACE_ID } from '@kbn/spaces-utils'; +import { + SCHEDULED_REPORTING_EXECUTE_TYPE, + ScheduledReportTaskParams, + ScheduledReportTaskParamsWithoutSpaceId, +} from '.'; +import type { SavedReport } from '../store'; +import { errorLogger } from './error_logger'; +import { SCHEDULED_REPORT_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { PrepareJobResults, RunReportTask } from './run_report'; +import { ScheduledReport } from '../store/scheduled_report'; +import { ScheduledReportType } from '../../types'; + +const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; // 10mb + +type ScheduledReportTaskInstance = Omit & { + params: Omit; +}; +export class RunScheduledReportTask extends RunReportTask { + public get TYPE() { + return SCHEDULED_REPORTING_EXECUTE_TYPE; + } + + protected async prepareJob(taskInstance: ConcreteTaskInstance): Promise { + const { runAt, params: scheduledReportTaskParams } = taskInstance; + + let report: SavedReport | undefined; + let jobId: string; + let scheduledReport: SavedObject | undefined; + const task = scheduledReportTaskParams as ScheduledReportTaskParams; + const scheduledReportId = task.id; + const reportSpaceId = task.spaceId || DEFAULT_SPACE_ID; + + try { + if (!scheduledReportId) { + throw new Error( + `Invalid scheduled_report saved object data provided in scheduled task! - No saved object with id "${scheduledReportId}"` + ); + } + + const internalSoClient = await this.opts.reporting.getInternalSoClient(); + scheduledReport = await internalSoClient.get( + SCHEDULED_REPORT_SAVED_OBJECT_TYPE, + scheduledReportId, + { namespace: reportSpaceId } + ); + + const store = await this.opts.reporting.getStore(); + + // Add the report to ReportingStore to show as processing + report = await store.addReport( + new ScheduledReport({ + runAt, + kibanaId: this.kibanaId!, + kibanaName: this.kibanaName!, + queueTimeout: this.queueTimeout, + scheduledReport, + }) + ); + + jobId = report._id; + if (!jobId) { + throw new Error(`Unable to store report document in ReportingStore`); + } + } catch (failedToClaim) { + // error claiming report - log the error + errorLogger( + this.logger, + `Error in running scheduled report ${scheduledReportId}`, + failedToClaim + ); + } + + return { + isLastAttempt: false, + jobId: jobId!, + report, + task: report?.toReportTaskJSON(), + scheduledReport, + }; + } + + protected getMaxAttempts() { + return undefined; + } + + protected async notify( + report: SavedReport, + taskInstance: ConcreteTaskInstance, + output: TaskRunResult, + byteSize: number, + scheduledReport?: SavedObject, + spaceId?: string + ): Promise { + try { + const { runAt, params } = taskInstance; + const task = params as ScheduledReportTaskParams; + if (!scheduledReport) { + const internalSoClient = await this.opts.reporting.getInternalSoClient(); + scheduledReport = await internalSoClient.get( + SCHEDULED_REPORT_SAVED_OBJECT_TYPE, + task.id, + { namespace: spaceId } + ); + } + + const { notification } = scheduledReport.attributes; + if (notification && notification.email) { + if (byteSize > MAX_ATTACHMENT_SIZE) { + throw new Error('The report is larger than the 10MB limit.'); + } + if (!this.emailNotificationService) { + throw new Error('Reporting notification service has not been initialized.'); + } + + const email = notification.email; + const title = scheduledReport.attributes.title; + const extension = this.getJobContentExtension(report.jobtype); + + await this.emailNotificationService.notify({ + reporting: this.opts.reporting, + index: report._index, + id: report._id, + filename: `${title}-${runAt.toISOString()}.${extension}`, + contentType: output.content_type, + relatedObject: { + id: scheduledReport.id, + type: scheduledReport.type, + namespace: spaceId, + }, + emailParams: { + to: email.to, + cc: email.cc, + bcc: email.bcc, + subject: `${title}-${runAt.toISOString()} scheduled report`, + spaceId, + }, + }); + } + } catch (error) { + const message = `Error sending notification for scheduled report: ${error.message}`; + this.saveExecutionWarning( + report, + { + ...output, + size: byteSize, + }, + message + ).catch((failedToSaveWarning) => { + errorLogger( + this.logger, + `Error in saving execution warning ${report._id}`, + failedToSaveWarning + ); + }); + } + } + + public getTaskDefinition() { + const queueTimeout = this.getQueueTimeout(); + const maxConcurrency = this.getMaxConcurrency(); + + return { + type: SCHEDULED_REPORTING_EXECUTE_TYPE, + title: 'Reporting: execute scheduled job', + createTaskRunner: this.getTaskRunner(), + timeout: queueTimeout, + maxConcurrency, + }; + } + + public async scheduleTask( + request: KibanaRequest, + params: ScheduledReportTaskParamsWithoutSpaceId + ) { + const spaceId = this.opts.reporting.getSpaceId(request, this.logger); + const taskInstance: ScheduledReportTaskInstance = { + id: params.id, + taskType: SCHEDULED_REPORTING_EXECUTE_TYPE, + state: {}, + params: { + id: params.id, + spaceId: spaceId || DEFAULT_SPACE_ID, + jobtype: params.jobtype, + }, + schedule: params.schedule, + }; + return await this.getTaskManagerStart().schedule(taskInstance, { request }); + } +} diff --git a/x-pack/platform/plugins/private/reporting/server/lib/tasks/execute_report.test.ts b/x-pack/platform/plugins/private/reporting/server/lib/tasks/run_single_report.test.ts similarity index 88% rename from x-pack/platform/plugins/private/reporting/server/lib/tasks/execute_report.test.ts rename to x-pack/platform/plugins/private/reporting/server/lib/tasks/run_single_report.test.ts index a8b5eb35f6ad..58c28dffffe2 100644 --- a/x-pack/platform/plugins/private/reporting/server/lib/tasks/execute_report.test.ts +++ b/x-pack/platform/plugins/private/reporting/server/lib/tasks/run_single_report.test.ts @@ -16,7 +16,7 @@ import { cryptoFactory, type ExportType, type ReportingConfigType } from '@kbn/r import type { RunContext } from '@kbn/task-manager-plugin/server'; import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; -import { ExecuteReportTask, REPORTING_EXECUTE_TYPE } from '.'; +import { RunSingleReportTask, REPORTING_EXECUTE_TYPE } from '.'; import { ReportingCore } from '../..'; import { createMockReportingCore } from '../../test_helpers'; import { FakeRawRequest, KibanaRequest } from '@kbn/core/server'; @@ -104,7 +104,7 @@ const fakeRawRequest: FakeRawRequest = { path: '/', }; -describe('Execute Report Task', () => { +describe('Run Single Report Task', () => { let mockReporting: ReportingCore; let configType: ReportingConfigType; beforeAll(async () => { @@ -113,7 +113,7 @@ describe('Execute Report Task', () => { }); it('Instance setup', () => { - const task = new ExecuteReportTask(mockReporting, configType, logger); + const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger }); expect(task.getStatus()).toBe('uninitialized'); expect(task.getTaskDefinition()).toMatchInlineSnapshot(` Object { @@ -129,7 +129,7 @@ describe('Execute Report Task', () => { it('Instance start', () => { const mockTaskManager = taskManagerMock.createStart(); - const task = new ExecuteReportTask(mockReporting, configType, logger); + const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger }); expect(task.init(mockTaskManager)); expect(task.getStatus()).toBe('initialized'); }); @@ -138,7 +138,7 @@ describe('Execute Report Task', () => { logger.info = jest.fn(); logger.error = jest.fn(); - const task = new ExecuteReportTask(mockReporting, configType, logger); + const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger }); const taskDef = task.getTaskDefinition(); const taskRunner = taskDef.createTaskRunner({ taskInstance: { @@ -155,7 +155,11 @@ describe('Execute Report Task', () => { queue: { pollEnabled: false, timeout: 55000 }, } as unknown as ReportingConfigType['queue']; - const task = new ExecuteReportTask(mockReporting, { ...configType, ...queueConfig }, logger); + const task = new RunSingleReportTask({ + reporting: mockReporting, + config: { ...configType, ...queueConfig }, + logger, + }); expect(task.getStatus()).toBe('uninitialized'); expect(task.getTaskDefinition()).toMatchInlineSnapshot(` Object { @@ -175,7 +179,7 @@ describe('Execute Report Task', () => { hasPermanentEncryptionKey: true, areNotificationsEnabled: true, }); - const task = new ExecuteReportTask(mockReporting, configType, logger); + const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger }); const mockTaskManager = taskManagerMock.createStart(); await task.init(mockTaskManager); @@ -208,7 +212,7 @@ describe('Execute Report Task', () => { hasPermanentEncryptionKey: true, areNotificationsEnabled: false, }); - const task = new ExecuteReportTask(mockReporting, configType, logger); + const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger }); const mockTaskManager = taskManagerMock.createStart(); await task.init(mockTaskManager); @@ -238,7 +242,7 @@ describe('Execute Report Task', () => { hasPermanentEncryptionKey: false, areNotificationsEnabled: true, }); - const task = new ExecuteReportTask(mockReporting, configType, logger); + const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger }); const mockTaskManager = taskManagerMock.createStart(); await task.init(mockTaskManager); @@ -275,14 +279,14 @@ describe('Execute Report Task', () => { jobType: 'test1', validLicenses: [], } as unknown as ExportType); - const task = new ExecuteReportTask(mockReporting, configType, logger); + const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger }); jest - // @ts-expect-error TS compilation fails: this overrides a private method of the ExecuteReportTask instance - .spyOn(task, '_claimJob') + // @ts-expect-error TS compilation fails: this overrides a private method of the RunSingleReportTask instance + .spyOn(task, 'claimJob') .mockResolvedValueOnce({ _id: 'test', jobtype: 'test1', status: 'pending' } as never); jest - // @ts-expect-error TS compilation fails: this overrides a private method of the ExecuteReportTask instance - .spyOn(task, '_completeJob') + // @ts-expect-error TS compilation fails: this overrides a private method of the RunSingleReportTask instance + .spyOn(task, 'completeJob') .mockResolvedValueOnce({ _id: 'test', jobtype: 'test1', status: 'pending' } as never); const mockTaskManager = taskManagerMock.createStart(); await task.init(mockTaskManager); @@ -320,14 +324,14 @@ describe('Execute Report Task', () => { jobType: 'test2', validLicenses: [], } as unknown as ExportType); - const task = new ExecuteReportTask(mockReporting, configType, logger); + const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger }); jest - // @ts-expect-error TS compilation fails: this overrides a private method of the ExecuteReportTask instance - .spyOn(task, '_claimJob') + // @ts-expect-error TS compilation fails: this overrides a private method of the RunSingleReportTask instance + .spyOn(task, 'claimJob') .mockResolvedValueOnce({ _id: 'test', jobtype: 'test2', status: 'pending' } as never); jest - // @ts-expect-error TS compilation fails: this overrides a private method of the ExecuteReportTask instance - .spyOn(task, '_completeJob') + // @ts-expect-error TS compilation fails: this overrides a private method of the RunSingleReportTask instance + .spyOn(task, 'completeJob') .mockResolvedValueOnce({ _id: 'test', jobtype: 'test2', status: 'pending' } as never); const mockTaskManager = taskManagerMock.createStart(); await task.init(mockTaskManager); @@ -367,14 +371,14 @@ describe('Execute Report Task', () => { jobType: 'test3', validLicenses: [], } as unknown as ExportType); - const task = new ExecuteReportTask(mockReporting, configType, logger); + const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger }); jest - // @ts-expect-error TS compilation fails: this overrides a private method of the ExecuteReportTask instance - .spyOn(task, '_claimJob') + // @ts-expect-error TS compilation fails: this overrides a private method of the RunSingleReportTask instance + .spyOn(task, 'claimJob') .mockResolvedValueOnce({ _id: 'test', jobtype: 'test3', status: 'pending' } as never); jest - // @ts-expect-error TS compilation fails: this overrides a private method of the ExecuteReportTask instance - .spyOn(task, '_completeJob') + // @ts-expect-error TS compilation fails: this overrides a private method of the RunSingleReportTask instance + .spyOn(task, 'completeJob') .mockResolvedValueOnce({ _id: 'test', jobtype: 'test3', status: 'pending' } as never); const mockTaskManager = taskManagerMock.createStart(); await task.init(mockTaskManager); @@ -421,10 +425,10 @@ describe('Execute Report Task', () => { status: 'processing', } as unknown as estypes.UpdateUpdateWriteResponseBase) ); - const task = new ExecuteReportTask(mockReporting, configType, logger); + const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger }); jest - // @ts-expect-error TS compilation fails: this overrides a private method of the ExecuteReportTask instance - .spyOn(task, '_claimJob') + // @ts-expect-error TS compilation fails: this overrides a private method of the RunSingleReportTask instance + .spyOn(task, 'claimJob') .mockResolvedValueOnce({ _id: 'test', jobtype: 'noop', status: 'pending' } as never); const mockTaskManager = taskManagerMock.createStart(); await task.init(mockTaskManager); diff --git a/x-pack/platform/plugins/private/reporting/server/lib/tasks/run_single_report.ts b/x-pack/platform/plugins/private/reporting/server/lib/tasks/run_single_report.ts new file mode 100644 index 000000000000..faf99680c366 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/lib/tasks/run_single_report.ts @@ -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 moment from 'moment'; +import type { KibanaRequest } from '@kbn/core/server'; +import { QueueTimeoutError, ReportingError } from '@kbn/reporting-common'; +import type { ConcreteTaskInstance, TaskInstance } from '@kbn/task-manager-plugin/server'; + +import { REPORTING_EXECUTE_TYPE, ReportTaskParams } from '.'; +import { + isExecutionError, + mapToReportingError, +} from '../../../common/errors/map_to_reporting_error'; +import { SavedReport } from '../store'; +import type { ReportProcessingFields } from '../store/store'; +import { errorLogger } from './error_logger'; +import { PrepareJobResults, RunReportTask } from './run_report'; + +type SingleReportTaskInstance = Omit & { + params: ReportTaskParams; +}; +export class RunSingleReportTask extends RunReportTask { + public get TYPE() { + return REPORTING_EXECUTE_TYPE; + } + + private async claimJob(task: ReportTaskParams): Promise { + const store = await this.opts.reporting.getStore(); + const report = await store.findReportFromTask(task); // receives seq_no and primary_term + const logger = this.logger.get(report._id); + + if (report.status === 'completed') { + throw new Error(`Can not claim the report job: it is already completed!`); + } + + const m = moment(); + + // check if job has exceeded the configured maxAttempts + const maxAttempts = this.getMaxAttempts(); + if (report.attempts >= maxAttempts) { + let err: ReportingError; + if (report.error && isExecutionError(report.error)) { + // We have an error stored from a previous attempts, so we'll use that + // error to fail the job and return it to the user. + const { error } = report; + err = mapToReportingError(error); + err.stack = error.stack; + } else { + if (report.error && report.error instanceof Error) { + errorLogger(logger, 'Error executing report', report.error); + } + err = new QueueTimeoutError( + `Max attempts reached (${maxAttempts}). Queue timeout reached.` + ); + } + await this.failJob(report, err); + throw err; + } + + const startTime = m.toISOString(); + const expirationTime = m.add(this.queueTimeout).toISOString(); + + const doc: ReportProcessingFields = { + kibana_id: this.kibanaId, + kibana_name: this.kibanaName, + attempts: report.attempts + 1, + max_attempts: maxAttempts, + started_at: startTime, + timeout: this.queueTimeout, + process_expiration: expirationTime, + }; + + const claimedReport = new SavedReport({ ...report, ...doc }); + + logger.info( + `Claiming ${claimedReport.jobtype} ${report._id} ` + + `[_index: ${report._index}] ` + + `[_seq_no: ${report._seq_no}] ` + + `[_primary_term: ${report._primary_term}] ` + + `[attempts: ${report.attempts}] ` + + `[process_expiration: ${expirationTime}]` + ); + + // event tracking of claimed job + const eventTracker = this.getEventTracker(report); + const timeSinceCreation = Date.now() - new Date(report.created_at).valueOf(); + eventTracker?.claimJob({ timeSinceCreation }); + + const resp = await store.setReportClaimed(claimedReport, doc); + claimedReport._seq_no = resp._seq_no!; + claimedReport._primary_term = resp._primary_term!; + return claimedReport; + } + + protected async prepareJob(taskInstance: ConcreteTaskInstance): Promise { + const { attempts: taskAttempts, params: reportTaskParams } = taskInstance; + + let report: SavedReport | undefined; + const isLastAttempt = taskAttempts >= this.getMaxAttempts(); + + // find the job in the store and set status to processing + const task = reportTaskParams as ReportTaskParams; + const jobId = task?.id; + + try { + if (!jobId) { + throw new Error('Invalid report data provided in scheduled task!'); + } + + // Update job status to claimed + report = await this.claimJob(task); + } catch (failedToClaim) { + // error claiming report - log the error + // could be version conflict, or too many attempts or no longer connected to ES + errorLogger(this.logger, `Error in claiming ${jobId}`, failedToClaim); + } + + return { isLastAttempt, jobId, report, task }; + } + + protected getMaxAttempts() { + return this.opts.config.capture.maxAttempts ?? 1; + } + + protected async notify(): Promise {} + + public getTaskDefinition() { + const queueTimeout = this.getQueueTimeout(); + const maxConcurrency = this.getMaxConcurrency(); + const maxAttempts = this.getMaxAttempts(); + + return { + type: REPORTING_EXECUTE_TYPE, + title: 'Reporting: execute job', + createTaskRunner: this.getTaskRunner(), + maxAttempts: maxAttempts + 1, // Add 1 so we get an extra attempt in case of failure during a Kibana restart + timeout: queueTimeout, + maxConcurrency, + }; + } + + public async scheduleTask(request: KibanaRequest, params: ReportTaskParams) { + const reportingHealth = await this.opts.reporting.getHealthInfo(); + const shouldScheduleWithApiKey = + reportingHealth.hasPermanentEncryptionKey && reportingHealth.isSufficientlySecure; + const taskInstance: SingleReportTaskInstance = { + taskType: REPORTING_EXECUTE_TYPE, + state: {}, + params, + }; + + return shouldScheduleWithApiKey + ? await this.getTaskManagerStart().schedule(taskInstance, { request }) + : await this.getTaskManagerStart().schedule(taskInstance); + } +} diff --git a/x-pack/platform/plugins/private/reporting/server/plugin.test.ts b/x-pack/platform/plugins/private/reporting/server/plugin.test.ts index 232279483698..59b15be74e93 100644 --- a/x-pack/platform/plugins/private/reporting/server/plugin.test.ts +++ b/x-pack/platform/plugins/private/reporting/server/plugin.test.ts @@ -76,6 +76,21 @@ describe('Reporting Plugin', () => { ); }); + it('registers a saved object for scheduled reports', async () => { + plugin.setup(coreSetup, pluginSetup); + expect(coreSetup.savedObjects.registerType).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'scheduled_report', + namespaceType: 'multiple', + hidden: true, + indexPattern: '.kibana_alerting_cases', + management: { + importableAndExportable: false, + }, + }) + ); + }); + it('logs start issues', async () => { // wait for the setup phase background work plugin.setup(coreSetup, pluginSetup); @@ -168,21 +183,37 @@ describe('Reporting Plugin', () => { }); describe('features registration', () => { - it('does not register Kibana reporting feature in traditional build flavour', async () => { + it('registers Kibana manage scheduled reporting feature in traditional build flavour', async () => { plugin.setup(coreSetup, pluginSetup); - expect(featuresSetup.registerKibanaFeature).not.toHaveBeenCalled(); + expect(featuresSetup.registerKibanaFeature).toHaveBeenCalledTimes(1); + expect(featuresSetup.registerKibanaFeature).toHaveBeenCalledWith({ + id: 'manageReporting', + name: 'Manage Scheduled Reports', + description: 'View and manage scheduled reports for all users in this space.', + category: DEFAULT_APP_CATEGORIES.management, + scope: ['spaces', 'security'], + app: [], + privileges: { + all: { + api: ['manage_scheduled_reports'], + savedObject: { all: ['scheduled_report'], read: [] }, + ui: ['show'], + }, + read: { disabled: true, savedObject: { all: [], read: [] }, ui: [] }, + }, + }); expect(featuresSetup.enableReportingUiCapabilities).toHaveBeenCalledTimes(1); }); - it('registers Kibana reporting feature in serverless build flavour', async () => { + it('registers additional Kibana reporting feature in serverless build flavour', async () => { const serverlessInitContext = coreMock.createPluginInitializerContext(configSchema); // Force type-cast to convert `ReadOnly` to mutable `PackageInfo`. (serverlessInitContext.env.packageInfo as PackageInfo).buildFlavor = 'serverless'; plugin = new ReportingPlugin(serverlessInitContext); plugin.setup(coreSetup, pluginSetup); - expect(featuresSetup.registerKibanaFeature).toHaveBeenCalledTimes(1); - expect(featuresSetup.registerKibanaFeature).toHaveBeenCalledWith({ + expect(featuresSetup.registerKibanaFeature).toHaveBeenCalledTimes(2); + expect(featuresSetup.registerKibanaFeature).toHaveBeenNthCalledWith(1, { id: 'reporting', name: 'Reporting', category: DEFAULT_APP_CATEGORIES.management, @@ -193,6 +224,22 @@ describe('Reporting Plugin', () => { read: { disabled: true, savedObject: { all: [], read: [] }, ui: [] }, }, }); + expect(featuresSetup.registerKibanaFeature).toHaveBeenNthCalledWith(2, { + id: 'manageReporting', + name: 'Manage Scheduled Reports', + description: 'View and manage scheduled reports for all users in this space.', + category: DEFAULT_APP_CATEGORIES.management, + scope: ['spaces', 'security'], + app: [], + privileges: { + all: { + api: ['manage_scheduled_reports'], + savedObject: { all: ['scheduled_report'], read: [] }, + ui: ['show'], + }, + read: { disabled: true, savedObject: { all: [], read: [] }, ui: [] }, + }, + }); expect(featuresSetup.enableReportingUiCapabilities).toHaveBeenCalledTimes(1); }); }); diff --git a/x-pack/platform/plugins/private/reporting/server/plugin.ts b/x-pack/platform/plugins/private/reporting/server/plugin.ts index 12bfb3decb80..42d0889b0847 100644 --- a/x-pack/platform/plugins/private/reporting/server/plugin.ts +++ b/x-pack/platform/plugins/private/reporting/server/plugin.ts @@ -27,6 +27,7 @@ import type { import { ReportingRequestHandlerContext } from './types'; import { registerReportingEventTypes, registerReportingUsageCollector } from './usage'; import { registerFeatures } from './features'; +import { setupSavedObjects } from './saved_objects'; /* * @internal @@ -75,6 +76,9 @@ export class ReportingPlugin registerReportingUsageCollector(reportingCore, plugins.usageCollection); registerReportingEventTypes(core); + // Saved objects + setupSavedObjects(core.savedObjects); + // Routes registerRoutes(reportingCore, this.logger); diff --git a/x-pack/platform/plugins/private/reporting/server/routes/common/audit_events.ts b/x-pack/platform/plugins/private/reporting/server/routes/common/audit_events.ts new file mode 100644 index 000000000000..b96abcf5876a --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/routes/common/audit_events.ts @@ -0,0 +1,78 @@ +/* + * 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 { EcsEvent } from '@kbn/core/server'; +import type { AuditEvent } from '@kbn/security-plugin/server'; +import type { ArrayElement } from '@kbn/utility-types'; + +export enum ScheduledReportAuditAction { + SCHEDULE = 'scheduled_report_schedule', + LIST = 'scheduled_report_list', + DISABLE = 'scheduled_report_disable', +} + +type VerbsTuple = [string, string, string]; + +const scheduledReportEventVerbs: Record = { + scheduled_report_schedule: ['create', 'creating', 'created'], + scheduled_report_list: ['access', 'accessing', 'accessed'], + scheduled_report_disable: ['disable', 'disabling', 'disabled'], +}; + +const scheduledReportEventTypes: Record< + ScheduledReportAuditAction, + ArrayElement +> = { + scheduled_report_schedule: 'creation', + scheduled_report_list: 'access', + scheduled_report_disable: 'change', +}; + +export interface ScheduledReportAuditEventParams { + action: ScheduledReportAuditAction; + outcome?: EcsEvent['outcome']; + savedObject?: NonNullable['saved_object']; + error?: Error; +} + +export function scheduledReportAuditEvent({ + action, + savedObject, + outcome, + error, +}: ScheduledReportAuditEventParams): AuditEvent { + const doc = savedObject + ? [`scheduled report [id=${savedObject.id}]`, savedObject.name && `[name=${savedObject.name}]`] + .filter(Boolean) + .join(' ') + : 'a scheduled report'; + + const [present, progressive, past] = scheduledReportEventVerbs[action]; + const message = error + ? `Failed attempt to ${present} ${doc}` + : outcome === 'unknown' + ? `User is ${progressive} ${doc}` + : `User has ${past} ${doc}`; + const type = scheduledReportEventTypes[action]; + + return { + message, + event: { + action, + category: ['database'], + type: type ? [type] : undefined, + outcome: outcome ?? (error ? 'failure' : 'success'), + }, + kibana: { + saved_object: savedObject, + }, + error: error && { + code: error.name, + message: error.message, + }, + }; +} diff --git a/x-pack/platform/plugins/private/reporting/server/routes/common/generate/request_handler.ts b/x-pack/platform/plugins/private/reporting/server/routes/common/generate/request_handler.ts deleted file mode 100644 index 7e0348d7b93c..000000000000 --- a/x-pack/platform/plugins/private/reporting/server/routes/common/generate/request_handler.ts +++ /dev/null @@ -1,264 +0,0 @@ -/* - * 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 moment from 'moment'; - -import { schema, TypeOf } from '@kbn/config-schema'; -import type { KibanaRequest, KibanaResponseFactory, Logger } from '@kbn/core/server'; -import { i18n } from '@kbn/i18n'; -import { PUBLIC_ROUTES } from '@kbn/reporting-common'; -import type { BaseParams } from '@kbn/reporting-common/types'; -import { cryptoFactory } from '@kbn/reporting-server'; -import rison from '@kbn/rison'; - -import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common'; -import { type Counters, getCounters } from '..'; -import type { ReportingCore } from '../../..'; -import { checkParamsVersion } from '../../../lib'; -import { Report } from '../../../lib/store'; -import type { - ReportingJobResponse, - ReportingRequestHandlerContext, - ReportingUser, -} from '../../../types'; - -export const handleUnavailable = (res: KibanaResponseFactory) => { - return res.custom({ statusCode: 503, body: 'Not Available' }); -}; - -const validation = { - params: schema.object({ exportType: schema.string({ minLength: 2 }) }), - body: schema.nullable(schema.object({ jobParams: schema.maybe(schema.string()) })), - query: schema.nullable(schema.object({ jobParams: schema.string({ defaultValue: '' }) })), -}; - -/** - * Handles the common parts of requests to generate a report - * Serves report job handling in the context of the request to generate the report - */ -export class RequestHandler { - constructor( - private reporting: ReportingCore, - private user: ReportingUser, - private context: ReportingRequestHandlerContext, - private path: string, - private req: KibanaRequest< - TypeOf<(typeof validation)['params']>, - TypeOf<(typeof validation)['query']>, - TypeOf<(typeof validation)['body']> - >, - private res: KibanaResponseFactory, - private logger: Logger - ) {} - - private async encryptHeaders() { - const { encryptionKey } = this.reporting.getConfig(); - const crypto = cryptoFactory(encryptionKey); - return await crypto.encrypt(this.req.headers); - } - - public async enqueueJob(exportTypeId: string, jobParams: BaseParams) { - const { reporting, logger, context, req, user } = this; - - const exportType = reporting.getExportTypesRegistry().getById(exportTypeId); - - if (exportType == null) { - throw new Error(`Export type ${exportTypeId} does not exist in the registry!`); - } - - const store = await reporting.getStore(); - - if (!exportType.createJob) { - throw new Error(`Export type ${exportTypeId} is not a valid instance!`); - } - - // 1. Ensure the incoming params have a version field (should be set by the UI) - jobParams.version = checkParamsVersion(jobParams, logger); - - // 2. Encrypt request headers to store for the running report job to authenticate itself with Kibana - const headers = await this.encryptHeaders(); - - // 3. Create a payload object by calling exportType.createJob(), and adding some automatic parameters - const job = await exportType.createJob(jobParams, context, req); - - const spaceId = reporting.getSpaceId(req, logger); - - const payload = { - ...job, - headers, - title: job.title, - objectType: jobParams.objectType, - browserTimezone: jobParams.browserTimezone, - version: jobParams.version, - spaceId, - }; - - // 4. Add the report to ReportingStore to show as pending - const report = await store.addReport( - new Report({ - jobtype: exportType.jobType, - created_by: user ? user.username : false, - payload, - migration_version: jobParams.version, - space_id: spaceId || DEFAULT_SPACE_ID, - meta: { - // telemetry fields - objectType: jobParams.objectType, - layout: jobParams.layout?.id, - isDeprecated: job.isDeprecated, - }, - }) - ); - logger.debug(`Successfully stored pending job: ${report._index}/${report._id}`); - - // 5. Schedule the report with Task Manager - const task = await reporting.scheduleTask(req, report.toReportTaskJSON()); - logger.info( - `Scheduled ${exportType.name} reporting task. Task ID: task:${task.id}. Report ID: ${report._id}` - ); - - // 6. Log the action with event log - reporting.getEventLogger(report, task).logScheduleTask(); - return report; - } - - public getJobParams(): BaseParams { - let jobParamsRison: null | string = null; - const req = this.req; - const res = this.res; - - if (req.body) { - const { jobParams: jobParamsPayload } = req.body; - jobParamsRison = jobParamsPayload ? jobParamsPayload : null; - } else if (req.query?.jobParams) { - const { jobParams: queryJobParams } = req.query; - if (queryJobParams) { - jobParamsRison = queryJobParams; - } else { - jobParamsRison = null; - } - } - - if (!jobParamsRison) { - throw res.customError({ - statusCode: 400, - body: 'A jobParams RISON string is required in the querystring or POST body', - }); - } - - let jobParams; - - try { - jobParams = rison.decode(jobParamsRison) as BaseParams | null; - if (!jobParams) { - throw res.customError({ - statusCode: 400, - body: 'Missing jobParams!', - }); - } - } catch (err) { - throw res.customError({ - statusCode: 400, - body: `invalid rison: ${jobParamsRison}`, - }); - } - - return jobParams; - } - - public static getValidation() { - return validation; - } - - public async handleGenerateRequest(exportTypeId: string, jobParams: BaseParams) { - const req = this.req; - const reporting = this.reporting; - - const counters = getCounters( - req.route.method, - this.path.replace(/{exportType}/, exportTypeId), - reporting.getUsageCounter() - ); - - // ensure the async dependencies are loaded - if (!this.context.reporting) { - return handleUnavailable(this.res); - } - - const licenseInfo = await this.reporting.getLicenseInfo(); - const licenseResults = licenseInfo[exportTypeId]; - - if (!licenseResults) { - return this.res.badRequest({ body: `Invalid export-type of ${exportTypeId}` }); - } - - if (!licenseResults.enableLinks) { - return this.res.forbidden({ body: licenseResults.message }); - } - - if (jobParams.browserTimezone && !moment.tz.zone(jobParams.browserTimezone)) { - return this.res.badRequest({ - body: `Invalid timezone "${jobParams.browserTimezone ?? ''}".`, - }); - } - - let report: Report | undefined; - try { - report = await this.enqueueJob(exportTypeId, jobParams); - const { basePath } = this.reporting.getServerInfo(); - const publicDownloadPath = basePath + PUBLIC_ROUTES.JOBS.DOWNLOAD_PREFIX; - - // return task manager's task information and the download URL - counters.usageCounter(); - const eventTracker = reporting.getEventTracker( - report._id, - exportTypeId, - jobParams.objectType - ); - eventTracker?.createReport({ - isDeprecated: Boolean(report.payload.isDeprecated), - isPublicApi: this.path.match(/internal/) === null, - }); - - return this.res.ok({ - headers: { 'content-type': 'application/json' }, - body: { - path: `${publicDownloadPath}/${report._id}`, - job: report.toApiJSON(), - }, - }); - } catch (err) { - return this.handleError(err, counters, report?.jobtype); - } - } - - private handleError(err: Error | Boom.Boom, counters: Counters, jobtype?: string) { - this.logger.error(err); - - if (err instanceof Boom.Boom) { - const statusCode = err.output.statusCode; - counters?.errorCounter(jobtype, statusCode); - - return this.res.customError({ - statusCode, - body: err.output.payload.message, - }); - } - - counters?.errorCounter(jobtype, 500); - - return this.res.customError({ - statusCode: 500, - body: - err?.message || - i18n.translate('xpack.reporting.errorHandler.unknownError', { - defaultMessage: 'Unknown error', - }), - }); - } -} diff --git a/x-pack/platform/plugins/private/reporting/server/routes/common/jobs/get_job_routes.ts b/x-pack/platform/plugins/private/reporting/server/routes/common/jobs/get_job_routes.ts index dab96944ea6e..3f2aa4fd5e3b 100644 --- a/x-pack/platform/plugins/private/reporting/server/routes/common/jobs/get_job_routes.ts +++ b/x-pack/platform/plugins/private/reporting/server/routes/common/jobs/get_job_routes.ts @@ -12,7 +12,7 @@ import { getCounters } from '..'; import { ReportingCore } from '../../..'; import { getContentStream } from '../../../lib'; import { ReportingRequestHandlerContext, ReportingUser } from '../../../types'; -import { handleUnavailable } from '../generate'; +import { handleUnavailable } from '../request_handler'; import { jobManagementPreRouting } from './job_management_pre_routing'; import { jobsQueryFactory } from './jobs_query'; diff --git a/x-pack/platform/plugins/private/reporting/server/routes/common/generate/request_handler.test.ts b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/generate_request_handler.test.ts similarity index 82% rename from x-pack/platform/plugins/private/reporting/server/routes/common/generate/request_handler.test.ts rename to x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/generate_request_handler.test.ts index 12216b999045..724e69448646 100644 --- a/x-pack/platform/plugins/private/reporting/server/routes/common/generate/request_handler.test.ts +++ b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/generate_request_handler.test.ts @@ -22,7 +22,7 @@ import { ReportingRequestHandlerContext, ReportingSetup, } from '../../../types'; -import { RequestHandler } from './request_handler'; +import { GenerateRequestHandler } from './generate_request_handler'; jest.mock('@kbn/reporting-server/crypto', () => ({ cryptoFactory: () => ({ @@ -68,7 +68,7 @@ describe('Handle request to generate', () => { let mockContext: ReturnType; let mockRequest: ReturnType; let mockResponseFactory: ReturnType; - let requestHandler: RequestHandler; + let requestHandler: GenerateRequestHandler; beforeEach(async () => { reportingCore = await createMockReportingCore(createMockConfigSchema({})); @@ -91,20 +91,23 @@ describe('Handle request to generate', () => { mockContext = getMockContext(); mockContext.reporting = Promise.resolve({} as ReportingSetup); - requestHandler = new RequestHandler( - reportingCore, - { username: 'testymcgee' }, - mockContext, - '/api/reporting/test/generate/pdf', - mockRequest, - mockResponseFactory, - mockLogger - ); + requestHandler = new GenerateRequestHandler({ + reporting: reportingCore, + user: { username: 'testymcgee' }, + context: mockContext, + path: '/api/reporting/test/generate/pdf', + req: mockRequest, + res: mockResponseFactory, + logger: mockLogger, + }); }); describe('Enqueue Job', () => { test('creates a report object to queue', async () => { - const report = await requestHandler.enqueueJob('printablePdfV2', mockJobParams); + const report = await requestHandler.enqueueJob({ + exportTypeId: 'printablePdfV2', + jobParams: mockJobParams, + }); const { _id, created_at: _created_at, payload, ...snapObj } = report; expect(snapObj).toMatchInlineSnapshot(` @@ -131,6 +134,7 @@ describe('Handle request to generate', () => { "output": null, "process_expiration": undefined, "queue_time_ms": undefined, + "scheduled_report_id": undefined, "space_id": "default", "started_at": undefined, "status": "pending", @@ -158,7 +162,10 @@ describe('Handle request to generate', () => { test('provides a default kibana version field for older POST URLs', async () => { // how do we handle the printable_pdf endpoint that isn't migrating to the class instance of export types? (mockJobParams as unknown as { version?: string }).version = undefined; - const report = await requestHandler.enqueueJob('printablePdfV2', mockJobParams); + const report = await requestHandler.enqueueJob({ + exportTypeId: 'printablePdfV2', + jobParams: mockJobParams, + }); const { _id, created_at: _created_at, ...snapObj } = report; expect(snapObj.payload.version).toBe('7.14.0'); @@ -207,10 +214,14 @@ describe('Handle request to generate', () => { }); }); - describe('handleGenerateRequest', () => { + describe('handleRequest', () => { test('disallows invalid export type', async () => { - expect(await requestHandler.handleGenerateRequest('neanderthals', mockJobParams)) - .toMatchInlineSnapshot(` + expect( + await requestHandler.handleRequest({ + exportTypeId: 'neanderthals', + jobParams: mockJobParams, + }) + ).toMatchInlineSnapshot(` Object { "body": "Invalid export-type of neanderthals", } @@ -225,8 +236,12 @@ describe('Handle request to generate', () => { }, })); - expect(await requestHandler.handleGenerateRequest('csv_searchsource', mockJobParams)) - .toMatchInlineSnapshot(` + expect( + await requestHandler.handleRequest({ + exportTypeId: 'csv_searchsource', + jobParams: mockJobParams, + }) + ).toMatchInlineSnapshot(` Object { "body": "seeing this means the license isn't supported", } @@ -234,30 +249,26 @@ describe('Handle request to generate', () => { }); test('disallows invalid browser timezone', async () => { - (reportingCore.getLicenseInfo as jest.Mock) = jest.fn(() => ({ - csv_searchsource: { - enableLinks: false, - message: `seeing this means the license isn't supported`, - }, - })); - expect( - await requestHandler.handleGenerateRequest('csv_searchsource', { - ...mockJobParams, - browserTimezone: 'America/Amsterdam', + await requestHandler.handleRequest({ + exportTypeId: 'csv_searchsource', + jobParams: { + ...mockJobParams, + browserTimezone: 'America/Amsterdam', + }, }) ).toMatchInlineSnapshot(` Object { - "body": "seeing this means the license isn't supported", + "body": "Invalid timezone \\"America/Amsterdam\\".", } `); }); test('generates the download path', async () => { - const { body } = (await requestHandler.handleGenerateRequest( - 'csv_searchsource', - mockJobParams - )) as unknown as { body: ReportingJobResponse }; + const { body } = (await requestHandler.handleRequest({ + exportTypeId: 'csv_searchsource', + jobParams: mockJobParams, + })) as unknown as { body: ReportingJobResponse }; expect(body.path).toMatch('/mock-server-basepath/api/reporting/jobs/download/mock-report-id'); }); diff --git a/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/generate_request_handler.ts b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/generate_request_handler.ts new file mode 100644 index 000000000000..9a835a668b99 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/generate_request_handler.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 { schema } from '@kbn/config-schema'; +import { PUBLIC_ROUTES } from '@kbn/reporting-common'; +import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common'; +import { getCounters } from '..'; +import { Report, SavedReport } from '../../../lib/store'; +import type { ReportingJobResponse } from '../../../types'; +import { RequestHandler, RequestParams } from './request_handler'; + +const validation = { + params: schema.object({ exportType: schema.string({ minLength: 2 }) }), + body: schema.nullable(schema.object({ jobParams: schema.maybe(schema.string()) })), + query: schema.nullable(schema.object({ jobParams: schema.string({ defaultValue: '' }) })), +}; + +/** + * Handles the common parts of requests to generate a report + * Serves report job handling in the context of the request to generate the report + */ +export class GenerateRequestHandler extends RequestHandler< + (typeof validation)['params'], + (typeof validation)['query'], + (typeof validation)['body'], + SavedReport +> { + public static getValidation() { + return validation; + } + + public async enqueueJob(params: RequestParams) { + const { exportTypeId, jobParams } = params; + const { reporting, logger, req, user } = this.opts; + + const store = await reporting.getStore(); + const { version, job, jobType, name } = await this.createJob(exportTypeId, jobParams); + + const spaceId = reporting.getSpaceId(req, logger); + + // Encrypt request headers to store for the running report job to authenticate itself with Kibana + const headers = await this.encryptHeaders(); + + const payload = { + ...job, + headers, + title: job.title, + objectType: jobParams.objectType, + browserTimezone: jobParams.browserTimezone, + version, + spaceId, + }; + + // Add the report to ReportingStore to show as pending + const report = await store.addReport( + new Report({ + jobtype: jobType, + created_by: user ? user.username : false, + payload, + migration_version: version, + space_id: spaceId || DEFAULT_SPACE_ID, + meta: { + // telemetry fields + objectType: jobParams.objectType, + layout: jobParams.layout?.id, + isDeprecated: job.isDeprecated, + }, + }) + ); + logger.debug(`Successfully stored pending job: ${report._index}/${report._id}`); + + // Schedule the report with Task Manager + const task = await reporting.scheduleTask(req, report.toReportTaskJSON()); + logger.info( + `Scheduled ${name} reporting task. Task ID: task:${task.id}. Report ID: ${report._id}` + ); + + // Log the action with event log + reporting.getEventLogger(report, task).logScheduleTask(); + return report; + } + + public async handleRequest(params: RequestParams) { + const { exportTypeId, jobParams } = params; + const { reporting, req, res, path } = this.opts; + + const counters = getCounters( + req.route.method, + path.replace(/{exportType}/, exportTypeId), + reporting.getUsageCounter() + ); + + const checkErrorResponse = await this.checkLicenseAndTimezone( + exportTypeId, + jobParams.browserTimezone + ); + if (checkErrorResponse) { + return checkErrorResponse; + } + + let report: Report | undefined; + try { + report = await this.enqueueJob(params); + const { basePath } = reporting.getServerInfo(); + const publicDownloadPath = basePath + PUBLIC_ROUTES.JOBS.DOWNLOAD_PREFIX; + + // return task manager's task information and the download URL + counters.usageCounter(); + const eventTracker = reporting.getEventTracker( + report._id, + exportTypeId, + jobParams.objectType + ); + eventTracker?.createReport({ + isDeprecated: Boolean(report.payload.isDeprecated), + isPublicApi: path.match(/internal/) === null, + }); + + return res.ok({ + headers: { 'content-type': 'application/json' }, + body: { + path: `${publicDownloadPath}/${report._id}`, + job: report.toApiJSON(), + }, + }); + } catch (err) { + return this.handleError(err, counters, report?.jobtype); + } + } +} diff --git a/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/index.ts b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/index.ts new file mode 100644 index 000000000000..185b6ec86c37 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/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 { handleUnavailable } from './request_handler'; +export { GenerateRequestHandler } from './generate_request_handler'; +export { ScheduleRequestHandler } from './schedule_request_handler'; diff --git a/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/lib/index.ts b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/lib/index.ts new file mode 100644 index 000000000000..256f5046051f --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/lib/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 { transformRawScheduledReportToReport } from './transform_raw_scheduled_report'; +export { transformRawScheduledReportToTaskParams } from './transform_raw_scheduled_report_to_task'; diff --git a/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/lib/transform_raw_scheduled_report.ts b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/lib/transform_raw_scheduled_report.ts new file mode 100644 index 000000000000..3006ff48bfad --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/lib/transform_raw_scheduled_report.ts @@ -0,0 +1,26 @@ +/* + * 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 { SavedObject } from '@kbn/core/server'; +import { ScheduledReportApiJSON, ScheduledReportType } from '../../../../types'; + +export function transformRawScheduledReportToReport( + rawScheduledReport: SavedObject +): ScheduledReportApiJSON { + const parsedPayload = JSON.parse(rawScheduledReport.attributes.payload); + return { + id: rawScheduledReport.id, + jobtype: rawScheduledReport.attributes.jobType, + created_at: rawScheduledReport.attributes.createdAt, + created_by: rawScheduledReport.attributes.createdBy as string | false, + payload: parsedPayload, + meta: rawScheduledReport.attributes.meta, + migration_version: rawScheduledReport.attributes.migrationVersion, + schedule: rawScheduledReport.attributes.schedule, + notification: rawScheduledReport.attributes.notification, + }; +} diff --git a/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/lib/transform_raw_scheduled_report_to_task.ts b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/lib/transform_raw_scheduled_report_to_task.ts new file mode 100644 index 000000000000..c221ae4d9249 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/lib/transform_raw_scheduled_report_to_task.ts @@ -0,0 +1,20 @@ +/* + * 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 { SavedObject } from '@kbn/core/server'; +import { ScheduledReportType } from '../../../../types'; +import { ScheduledReportTaskParamsWithoutSpaceId } from '../../../../lib/tasks'; + +export function transformRawScheduledReportToTaskParams( + rawScheduledReport: SavedObject +): ScheduledReportTaskParamsWithoutSpaceId { + return { + id: rawScheduledReport.id, + jobtype: rawScheduledReport.attributes.jobType, + schedule: rawScheduledReport.attributes.schedule, + }; +} diff --git a/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/request_handler.ts b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/request_handler.ts new file mode 100644 index 000000000000..008a218dfd21 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/request_handler.ts @@ -0,0 +1,208 @@ +/* + * 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 moment from 'moment'; +import { schema, TypeOf } from '@kbn/config-schema'; +import type { + IKibanaResponse, + KibanaRequest, + KibanaResponseFactory, + Logger, +} from '@kbn/core/server'; +import { i18n } from '@kbn/i18n'; +import type { BaseParams } from '@kbn/reporting-common/types'; +import { cryptoFactory } from '@kbn/reporting-server'; +import rison from '@kbn/rison'; + +import { RruleSchedule } from '@kbn/task-manager-plugin/server'; +import { RawNotification } from '../../../saved_objects/scheduled_report/schemas/latest'; +import { checkParamsVersion } from '../../../lib'; +import { type Counters } from '..'; +import type { ReportingCore } from '../../..'; +import type { ReportingRequestHandlerContext, ReportingUser } from '../../../types'; + +export const handleUnavailable = (res: KibanaResponseFactory) => { + return res.custom({ statusCode: 503, body: 'Not Available' }); +}; + +const ParamsValidation = schema.recordOf(schema.string(), schema.string()); +const QueryValidation = schema.nullable( + schema.recordOf(schema.string(), schema.maybe(schema.string())) +); +const BodyValidation = schema.nullable( + schema.recordOf(schema.string(), schema.maybe(schema.any())) +); + +interface ConstructorOpts< + Params extends typeof ParamsValidation, + Query extends typeof QueryValidation, + Body extends typeof BodyValidation +> { + reporting: ReportingCore; + user: ReportingUser; + context: ReportingRequestHandlerContext; + path: string; + req: KibanaRequest, TypeOf, TypeOf>; + res: KibanaResponseFactory; + logger: Logger; +} + +export interface RequestParams { + exportTypeId: string; + jobParams: BaseParams; + id?: string; + schedule?: RruleSchedule; + notification?: RawNotification; +} + +/** + * Handles the common parts of requests to generate or schedule a report + * Serves report job handling in the context of the request to generate the report + */ +export abstract class RequestHandler< + Params extends typeof ParamsValidation, + Query extends typeof QueryValidation, + Body extends typeof BodyValidation, + Output extends Record +> { + constructor(protected readonly opts: ConstructorOpts) {} + + public static getValidation() { + throw new Error('getValidation() must be implemented in a subclass'); + } + + public abstract enqueueJob(params: RequestParams): Promise; + + public abstract handleRequest(params: RequestParams): Promise; + + public getJobParams(): BaseParams { + let jobParamsRison: null | string = null; + const req = this.opts.req; + const res = this.opts.res; + + if (req.body) { + const { jobParams: jobParamsPayload } = req.body; + jobParamsRison = jobParamsPayload ? jobParamsPayload : null; + } else if (req.query?.jobParams) { + const { jobParams: queryJobParams } = req.query; + if (queryJobParams) { + jobParamsRison = queryJobParams; + } else { + jobParamsRison = null; + } + } + + if (!jobParamsRison) { + throw res.customError({ + statusCode: 400, + body: 'A jobParams RISON string is required in the querystring or POST body', + }); + } + + let jobParams; + + try { + jobParams = rison.decode(jobParamsRison) as BaseParams | null; + if (!jobParams) { + throw res.customError({ + statusCode: 400, + body: 'Missing jobParams!', + }); + } + } catch (err) { + throw res.customError({ + statusCode: 400, + body: `invalid rison: ${jobParamsRison}`, + }); + } + + return jobParams; + } + + protected async createJob(exportTypeId: string, jobParams: BaseParams) { + const exportType = this.opts.reporting.getExportTypesRegistry().getById(exportTypeId); + + if (exportType == null) { + throw new Error(`Export type ${exportTypeId} does not exist in the registry!`); + } + + if (!exportType.createJob) { + throw new Error(`Export type ${exportTypeId} is not a valid instance!`); + } + + // 1. Ensure the incoming params have a version field (should be set by the UI) + const version = checkParamsVersion(jobParams, this.opts.logger); + + // 2. Create a payload object by calling exportType.createJob(), and adding some automatic parameters + const job = await exportType.createJob(jobParams, this.opts.context, this.opts.req); + + return { job, version, jobType: exportType.jobType, name: exportType.name }; + } + + protected async checkLicenseAndTimezone( + exportTypeId: string, + browserTimezone: string + ): Promise { + const { reporting, context, res } = this.opts; + + // ensure the async dependencies are loaded + if (!context.reporting) { + return handleUnavailable(res); + } + + const licenseInfo = await reporting.getLicenseInfo(); + const licenseResults = licenseInfo[exportTypeId]; + + if (!licenseResults) { + return res.badRequest({ body: `Invalid export-type of ${exportTypeId}` }); + } + + if (!licenseResults.enableLinks) { + return res.forbidden({ body: licenseResults.message }); + } + + if (browserTimezone && !moment.tz.zone(browserTimezone)) { + return res.badRequest({ + body: `Invalid timezone "${browserTimezone ?? ''}".`, + }); + } + + return null; + } + + protected async encryptHeaders() { + const { encryptionKey } = this.opts.reporting.getConfig(); + const crypto = cryptoFactory(encryptionKey); + return await crypto.encrypt(this.opts.req.headers); + } + + protected handleError(err: Error | Boom.Boom, counters?: Counters, jobtype?: string) { + this.opts.logger.error(err); + + if (err instanceof Boom.Boom) { + const statusCode = err.output.statusCode; + counters?.errorCounter(jobtype, statusCode); + + return this.opts.res.customError({ + statusCode, + body: err.output.payload.message, + }); + } + + counters?.errorCounter(jobtype, 500); + + return this.opts.res.customError({ + statusCode: 500, + body: + err?.message || + i18n.translate('xpack.reporting.errorHandler.unknownError', { + defaultMessage: 'Unknown error', + }), + }); + } +} diff --git a/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/schedule_request_handler.test.ts b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/schedule_request_handler.test.ts new file mode 100644 index 000000000000..89e68b7939e9 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/schedule_request_handler.test.ts @@ -0,0 +1,791 @@ +/* + * 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. + */ + +jest.mock('uuid', () => ({ v4: () => 'mock-report-id' })); + +import rison from '@kbn/rison'; + +import { + AuditLogger, + FakeRawRequest, + KibanaRequest, + KibanaResponseFactory, + SavedObjectsClientContract, +} from '@kbn/core/server'; +import { coreMock, httpServerMock, loggingSystemMock } from '@kbn/core/server/mocks'; +import { JobParamsPDFV2 } from '@kbn/reporting-export-types-pdf-common'; +import { createMockConfigSchema } from '@kbn/reporting-mocks-server'; +import { ReportingCore } from '../../..'; +import { + createMockPluginSetup, + createMockPluginStart, + createMockReportingCore, +} from '../../../test_helpers'; +import { ReportingRequestHandlerContext, ReportingSetup } from '../../../types'; +import { ScheduleRequestHandler } from './schedule_request_handler'; +import { TaskStatus } from '@kbn/task-manager-plugin/server'; +import { licensingMock } from '@kbn/licensing-plugin/server/mocks'; +import { BehaviorSubject } from 'rxjs'; + +const getMockContext = () => + ({ + core: coreMock.createRequestHandlerContext(), + } as unknown as ReportingRequestHandlerContext); + +const getMockRequest = () => + ({ + url: { port: '5601', search: '', pathname: '/foo' }, + route: { path: '/foo', options: {} }, + } as KibanaRequest); + +const getMockResponseFactory = () => + ({ + ...httpServerMock.createResponseFactory(), + forbidden: (obj: unknown) => obj, + unauthorized: (obj: unknown) => obj, + customError: (err: unknown) => err, + } as unknown as KibanaResponseFactory); + +const mockLogger = loggingSystemMock.createLogger(); +const mockJobParams: JobParamsPDFV2 = { + browserTimezone: 'UTC', + objectType: 'cool_object_type', + title: 'cool_title', + version: 'unknown', + layout: { id: 'preserve_layout' }, + locatorParams: [], +}; + +const fakeRawRequest: FakeRawRequest = { + headers: { + authorization: `ApiKey skdjtq4u543yt3rhewrh`, + }, + path: '/', +}; + +describe('Handle request to schedule', () => { + let reportingCore: ReportingCore; + let mockContext: ReturnType; + let mockRequest: ReturnType; + let mockResponseFactory: ReturnType; + let requestHandler: ScheduleRequestHandler; + let soClient: SavedObjectsClientContract; + let auditLogger: AuditLogger; + + beforeEach(async () => { + jest.clearAllMocks(); + const mockConfig = createMockConfigSchema({}); + reportingCore = await createMockReportingCore( + mockConfig, + createMockPluginSetup({}), + await createMockPluginStart( + { + licensing: { + ...licensingMock.createStart(), + license$: new BehaviorSubject({ + isAvailable: true, + isActive: true, + type: 'platinum', + getFeature: () => true, + }), + }, + }, + mockConfig + ) + ); + + mockRequest = getMockRequest(); + + mockResponseFactory = getMockResponseFactory(); + (mockResponseFactory.ok as jest.Mock) = jest.fn((args: unknown) => args); + (mockResponseFactory.forbidden as jest.Mock) = jest.fn((args: unknown) => args); + (mockResponseFactory.badRequest as jest.Mock) = jest.fn((args: unknown) => args); + + mockContext = getMockContext(); + mockContext.reporting = Promise.resolve({} as ReportingSetup); + + auditLogger = await reportingCore.getAuditLogger(fakeRawRequest as unknown as KibanaRequest); + auditLogger.log = jest.fn(); + soClient = await reportingCore.getScopedSoClient(fakeRawRequest as unknown as KibanaRequest); + soClient.create = jest.fn().mockImplementation(async (_, opts) => { + return { + id: 'foo', + attributes: opts, + type: 'scheduled-report', + }; + }); + + jest.spyOn(reportingCore, 'scheduleRecurringTask').mockResolvedValue({ + id: 'task-id', + scheduledAt: new Date(), + attempts: 0, + status: TaskStatus.Idle, + runAt: new Date(), + startedAt: new Date(), + retryAt: new Date(), + state: {}, + ownerId: 'reporting', + taskType: 'reporting:printable_pdf_v2', + params: {}, + }); + + jest.spyOn(reportingCore, 'getHealthInfo').mockResolvedValue({ + isSufficientlySecure: true, + hasPermanentEncryptionKey: true, + areNotificationsEnabled: true, + }); + + requestHandler = new ScheduleRequestHandler({ + reporting: reportingCore, + user: { username: 'testymcgee' }, + context: mockContext, + path: '/api/reporting/test/generate/pdf', + // @ts-ignore + req: mockRequest, + res: mockResponseFactory, + logger: mockLogger, + }); + }); + + describe('enqueueJob', () => { + test('creates a scheduled_report saved object and schedules task', async () => { + const report = await requestHandler.enqueueJob({ + exportTypeId: 'printablePdfV2', + jobParams: mockJobParams, + schedule: { rrule: { freq: 1, interval: 2, tzid: 'UTC' } }, + }); + + const { id, created_at: _created_at, payload, ...snapObj } = report; + expect(snapObj).toMatchInlineSnapshot(` + Object { + "created_by": "testymcgee", + "jobtype": "printable_pdf_v2", + "meta": Object { + "isDeprecated": false, + "layout": "preserve_layout", + "objectType": "cool_object_type", + }, + "migration_version": "unknown", + "notification": undefined, + "schedule": Object { + "rrule": Object { + "freq": 1, + "interval": 2, + "tzid": "UTC", + }, + }, + } + `); + expect(payload).toMatchInlineSnapshot(` + Object { + "browserTimezone": "UTC", + "isDeprecated": false, + "layout": Object { + "id": "preserve_layout", + }, + "locatorParams": Array [], + "objectType": "cool_object_type", + "title": "cool_title", + "version": "unknown", + } + `); + + expect(auditLogger.log).toHaveBeenCalledWith({ + event: { + action: 'scheduled_report_schedule', + category: ['database'], + outcome: 'unknown', + type: ['creation'], + }, + kibana: { + saved_object: { id: 'mock-report-id', name: 'cool_title', type: 'scheduled_report' }, + }, + message: 'User is creating scheduled report [id=mock-report-id] [name=cool_title]', + }); + + expect(soClient.create).toHaveBeenCalledWith( + 'scheduled_report', + { + jobType: 'printable_pdf_v2', + createdAt: expect.any(String), + createdBy: 'testymcgee', + title: 'cool_title', + enabled: true, + payload: JSON.stringify(payload), + schedule: { + rrule: { + freq: 1, + interval: 2, + tzid: 'UTC', + }, + }, + migrationVersion: 'unknown', + meta: { + objectType: 'cool_object_type', + layout: 'preserve_layout', + isDeprecated: false, + }, + }, + { id: 'mock-report-id' } + ); + + expect(reportingCore.scheduleRecurringTask).toHaveBeenCalledWith(mockRequest, { + id: 'foo', + jobtype: 'printable_pdf_v2', + schedule: { rrule: { freq: 1, interval: 2, tzid: 'UTC' } }, + }); + }); + + test('creates a scheduled_report saved object with notification', async () => { + const report = await requestHandler.enqueueJob({ + exportTypeId: 'printablePdfV2', + jobParams: mockJobParams, + schedule: { rrule: { freq: 1, interval: 2, tzid: 'UTC' } }, + notification: { email: { to: ['a@b.com'] } }, + }); + + const { id, created_at: _created_at, payload, ...snapObj } = report; + expect(snapObj).toMatchInlineSnapshot(` + Object { + "created_by": "testymcgee", + "jobtype": "printable_pdf_v2", + "meta": Object { + "isDeprecated": false, + "layout": "preserve_layout", + "objectType": "cool_object_type", + }, + "migration_version": "unknown", + "notification": Object { + "email": Object { + "to": Array [ + "a@b.com", + ], + }, + }, + "schedule": Object { + "rrule": Object { + "freq": 1, + "interval": 2, + "tzid": "UTC", + }, + }, + } + `); + expect(payload).toMatchInlineSnapshot(` + Object { + "browserTimezone": "UTC", + "isDeprecated": false, + "layout": Object { + "id": "preserve_layout", + }, + "locatorParams": Array [], + "objectType": "cool_object_type", + "title": "cool_title", + "version": "unknown", + } + `); + + expect(auditLogger.log).toHaveBeenCalledWith({ + event: { + action: 'scheduled_report_schedule', + category: ['database'], + outcome: 'unknown', + type: ['creation'], + }, + kibana: { + saved_object: { id: 'mock-report-id', name: 'cool_title', type: 'scheduled_report' }, + }, + message: 'User is creating scheduled report [id=mock-report-id] [name=cool_title]', + }); + + expect(soClient.create).toHaveBeenCalledWith( + 'scheduled_report', + { + jobType: 'printable_pdf_v2', + createdAt: expect.any(String), + createdBy: 'testymcgee', + title: 'cool_title', + enabled: true, + payload: JSON.stringify(payload), + schedule: { + rrule: { + freq: 1, + interval: 2, + tzid: 'UTC', + }, + }, + migrationVersion: 'unknown', + meta: { + objectType: 'cool_object_type', + layout: 'preserve_layout', + isDeprecated: false, + }, + notification: { email: { to: ['a@b.com'] } }, + }, + { id: 'mock-report-id' } + ); + }); + + test('throws errors from so client create', async () => { + soClient.create = jest.fn().mockImplementationOnce(async () => { + throw new Error('SO create error'); + }); + + await expect( + requestHandler.enqueueJob({ + exportTypeId: 'printablePdfV2', + jobParams: mockJobParams, + schedule: { rrule: { freq: 1, interval: 2, tzid: 'UTC' } }, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"SO create error"`); + }); + }); + + describe('getJobParams', () => { + test('parse jobParams from body', () => { + // @ts-ignore body is a read-only property + mockRequest.body = { jobParams: rison.encode(mockJobParams) }; + expect(requestHandler.getJobParams()).toEqual(mockJobParams); + }); + + test('handles missing job params', () => { + let error: { statusCode: number; body: string } | undefined; + try { + requestHandler.getJobParams(); + } catch (err) { + error = err; + } + + expect(error?.statusCode).toBe(400); + }); + + test('handles null job params', () => { + let error: { statusCode: number; body: string } | undefined; + try { + // @ts-ignore body is a read-only property + mockRequest.body = { jobParams: rison.encode(null) }; + requestHandler.getJobParams(); + } catch (err) { + error = err; + } + + expect(error?.statusCode).toBe(400); + }); + + test('handles invalid rison', () => { + let error: { statusCode: number; body: string } | undefined; + // @ts-ignore body is a read-only property + mockRequest.body = { jobParams: mockJobParams }; + try { + requestHandler.getJobParams(); + } catch (err) { + error = err; + } + + expect(error?.statusCode).toBe(400); + }); + }); + + describe('getSchedule', () => { + test('parse schedule from body', () => { + // @ts-ignore body is a read-only property + mockRequest.body = { + jobParams: rison.encode(mockJobParams), + schedule: { rrule: { freq: 1, interval: 2 } }, + }; + expect(requestHandler.getSchedule()).toEqual({ rrule: { freq: 1, interval: 2 } }); + }); + + test('handles missing schedule', () => { + let error: { statusCode: number; body: string } | undefined; + try { + // @ts-ignore body is a read-only property + mockRequest.body = { + jobParams: rison.encode(mockJobParams), + }; + requestHandler.getSchedule(); + } catch (err) { + error = err; + } + + expect(error?.statusCode).toBe(400); + expect(error?.body).toBe('A schedule is required to create a scheduled report.'); + }); + + test('handles null schedule', () => { + let error: { statusCode: number; body: string } | undefined; + try { + // @ts-ignore body is a read-only property + mockRequest.body = { + jobParams: rison.encode(mockJobParams), + schedule: null, + }; + requestHandler.getSchedule(); + } catch (err) { + error = err; + } + + expect(error?.statusCode).toBe(400); + expect(error?.body).toBe('A schedule is required to create a scheduled report.'); + }); + + test('handles empty schedule', () => { + let error: { statusCode: number; body: string } | undefined; + try { + // @ts-ignore body is a read-only property + mockRequest.body = { + jobParams: rison.encode(mockJobParams), + schedule: {}, + }; + requestHandler.getSchedule(); + } catch (err) { + error = err; + } + + expect(error?.statusCode).toBe(400); + expect(error?.body).toBe('A schedule is required to create a scheduled report.'); + }); + + test('handles null rrule schedule', () => { + let error: { statusCode: number; body: string } | undefined; + try { + // @ts-ignore body is a read-only property + mockRequest.body = { + jobParams: rison.encode(mockJobParams), + schedule: { rrule: null }, + }; + requestHandler.getSchedule(); + } catch (err) { + error = err; + } + + expect(error?.statusCode).toBe(400); + expect(error?.body).toBe('A schedule is required to create a scheduled report.'); + }); + + test('handles empty rrule schedule', () => { + let error: { statusCode: number; body: string } | undefined; + try { + // @ts-ignore body is a read-only property + mockRequest.body = { + jobParams: rison.encode(mockJobParams), + schedule: { rrule: {} }, + }; + requestHandler.getSchedule(); + } catch (err) { + error = err; + } + + expect(error?.statusCode).toBe(400); + expect(error?.body).toBe('A schedule is required to create a scheduled report.'); + }); + }); + + describe('getNotification', () => { + test('parse notification from body', () => { + // @ts-ignore body is a read-only property + mockRequest.body = { + jobParams: rison.encode(mockJobParams), + schedule: { rrule: { freq: 1, interval: 2 } }, + notification: { email: { to: ['a@b.com'] } }, + }; + expect(requestHandler.getNotification()).toEqual({ email: { to: ['a@b.com'] } }); + }); + + test('parse notification from body when no to defined', () => { + // @ts-ignore body is a read-only property + mockRequest.body = { + jobParams: rison.encode(mockJobParams), + schedule: { rrule: { freq: 1, interval: 2 } }, + notification: { email: { bcc: ['a@b.com'] } }, + }; + expect(requestHandler.getNotification()).toEqual({ email: { bcc: ['a@b.com'] } }); + }); + + test('returns undefined if notification object is empty', () => { + // @ts-ignore body is a read-only property + mockRequest.body = { + jobParams: rison.encode(mockJobParams), + schedule: { rrule: { freq: 1, interval: 2 } }, + notification: {}, + }; + expect(requestHandler.getNotification()).toBeUndefined(); + }); + + test('returns undefined if notification object is null', () => { + // @ts-ignore body is a read-only property + mockRequest.body = { + jobParams: rison.encode(mockJobParams), + schedule: { rrule: { freq: 1, interval: 2 } }, + notification: null, + }; + expect(requestHandler.getNotification()).toBeUndefined(); + }); + + test('returns undefined if notification.email object is empty', () => { + // @ts-ignore body is a read-only property + mockRequest.body = { + jobParams: rison.encode(mockJobParams), + schedule: { rrule: { freq: 1, interval: 2 } }, + notification: { email: {} }, + }; + expect(requestHandler.getNotification()).toBeUndefined(); + }); + + test('returns undefined if notification.email arrays are all empty', () => { + // @ts-ignore body is a read-only property + mockRequest.body = { + jobParams: rison.encode(mockJobParams), + schedule: { rrule: { freq: 1, interval: 2 } }, + notification: { email: { to: [], cc: [], bcc: [] } }, + }; + expect(requestHandler.getNotification()).toBeUndefined(); + }); + + test('returns undefined if notification.email object is null', () => { + // @ts-ignore body is a read-only property + mockRequest.body = { + jobParams: rison.encode(mockJobParams), + schedule: { rrule: { freq: 1, interval: 2 } }, + notification: { email: null }, + }; + expect(requestHandler.getNotification()).toBeUndefined(); + }); + + test('handles invalid email address', () => { + jest + .spyOn(reportingCore, 'validateNotificationEmails') + .mockReturnValueOnce('not valid emails: foo'); + let error: { statusCode: number; body: string } | undefined; + try { + // @ts-ignore body is a read-only property + mockRequest.body = { + jobParams: rison.encode(mockJobParams), + schedule: { rrule: { freq: 1, interval: 2 } }, + notification: { email: { to: ['foo'] } }, + }; + requestHandler.getNotification(); + } catch (err) { + error = err; + } + + expect(error?.statusCode).toBe(400); + expect(error?.body).toBe('Invalid email address(es): not valid emails: foo'); + }); + + test('handles too many recipients', () => { + let error: { statusCode: number; body: string } | undefined; + try { + // @ts-ignore body is a read-only property + mockRequest.body = { + jobParams: rison.encode(mockJobParams), + schedule: { rrule: { freq: 1, interval: 2 } }, + notification: { + email: { + to: [ + '1@elastic.co', + '2@elastic.co', + '3@elastic.co', + '4@elastic.co', + '5@elastic.co', + '6@elastic.co', + '7@elastic.co', + ], + cc: [ + '8@elastic.co', + '9@elastic.co', + '10@elastic.co', + '11@elastic.co', + '12@elastic.co', + '13@elastic.co', + '14@elastic.co', + '15@elastic.co', + '16@elastic.co', + '17@elastic.co', + ], + bcc: [ + '18@elastic.co', + '19@elastic.co', + '20@elastic.co', + '21@elastic.co', + '22@elastic.co', + '23@elastic.co', + '24@elastic.co', + '25@elastic.co', + '26@elastic.co', + '27@elastic.co', + '28@elastic.co', + '29@elastic.co', + '30@elastic.co', + '31@elastic.co', + ], + }, + }, + }; + requestHandler.getNotification(); + } catch (err) { + error = err; + } + + expect(error?.statusCode).toBe(400); + expect(error?.body).toBe( + 'Maximum number of recipients exceeded: cannot specify more than 30 recipients.' + ); + }); + }); + + describe('handleRequest', () => { + test('disallows invalid export type', async () => { + expect( + await requestHandler.handleRequest({ + exportTypeId: 'neanderthals', + jobParams: mockJobParams, + }) + ).toMatchInlineSnapshot(` + Object { + "body": "Invalid export-type of neanderthals", + } + `); + }); + + test('disallows unsupporting license', async () => { + (reportingCore.getLicenseInfo as jest.Mock) = jest.fn(() => ({ + scheduledReports: { + enableLinks: false, + message: `seeing this means the license isn't supported`, + }, + })); + + expect( + await requestHandler.handleRequest({ + exportTypeId: 'csv_searchsource', + jobParams: mockJobParams, + }) + ).toMatchInlineSnapshot(` + Object { + "body": "seeing this means the license isn't supported", + } + `); + }); + + test('disallows invalid browser timezone', async () => { + expect( + await requestHandler.handleRequest({ + exportTypeId: 'csv_searchsource', + jobParams: { + ...mockJobParams, + browserTimezone: 'America/Amsterdam', + }, + }) + ).toMatchInlineSnapshot(` + Object { + "body": "Invalid timezone \\"America/Amsterdam\\".", + } + `); + }); + + test('disallows scheduling when user is "false"', async () => { + requestHandler = new ScheduleRequestHandler({ + reporting: reportingCore, + user: false, + context: mockContext, + path: '/api/reporting/test/generate/pdf', + // @ts-ignore + req: mockRequest, + res: mockResponseFactory, + logger: mockLogger, + }); + expect( + await requestHandler.handleRequest({ + exportTypeId: 'csv_searchsource', + jobParams: mockJobParams, + }) + ).toMatchInlineSnapshot(` + Object { + "body": "User must be authenticated to schedule a report", + } + `); + }); + + test('disallows scheduling when reportingHealth.hasPermanentEncryptionKey = false', async () => { + jest.spyOn(reportingCore, 'getHealthInfo').mockResolvedValueOnce({ + isSufficientlySecure: true, + hasPermanentEncryptionKey: false, + areNotificationsEnabled: true, + }); + + expect( + await requestHandler.handleRequest({ + exportTypeId: 'csv_searchsource', + jobParams: mockJobParams, + }) + ).toMatchInlineSnapshot(` + Object { + "body": "Permanent encryption key must be set for scheduled reporting", + } + `); + }); + + test('disallows scheduling when reportingHealth.isSufficientlySecure=false', async () => { + jest.spyOn(reportingCore, 'getHealthInfo').mockResolvedValueOnce({ + isSufficientlySecure: false, + hasPermanentEncryptionKey: true, + areNotificationsEnabled: true, + }); + + expect( + await requestHandler.handleRequest({ + exportTypeId: 'csv_searchsource', + jobParams: mockJobParams, + }) + ).toMatchInlineSnapshot(` + Object { + "body": "Security and API keys must be enabled for scheduled reporting", + } + `); + }); + + test('handles errors from so client create', async () => { + soClient.create = jest.fn().mockImplementationOnce(async () => { + throw new Error('SO create error'); + }); + + expect( + await requestHandler.handleRequest({ + exportTypeId: 'csv_searchsource', + jobParams: mockJobParams, + }) + ).toMatchInlineSnapshot(` + Object { + "body": "SO create error", + "statusCode": 500, + } + `); + + expect(auditLogger.log).toHaveBeenCalledWith({ + error: { + code: 'Error', + message: 'SO create error', + }, + event: { + action: 'scheduled_report_schedule', + category: ['database'], + outcome: 'failure', + type: ['creation'], + }, + kibana: { + saved_object: { + id: 'mock-report-id', + type: 'scheduled_report', + name: 'cool_title', + }, + }, + message: 'Failed attempt to create scheduled report [id=mock-report-id] [name=cool_title]', + }); + }); + }); +}); diff --git a/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/schedule_request_handler.ts b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/schedule_request_handler.ts new file mode 100644 index 000000000000..2a37c9851334 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/schedule_request_handler.ts @@ -0,0 +1,249 @@ +/* + * 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 moment from 'moment'; + +import { schema } from '@kbn/config-schema'; +import { isEmpty, omit } from 'lodash'; +import { RruleSchedule, scheduleRruleSchema } from '@kbn/task-manager-plugin/server'; +import { SavedObjectsUtils } from '@kbn/core/server'; +import { IKibanaResponse } from '@kbn/core/server'; +import { RawNotification } from '../../../saved_objects/scheduled_report/schemas/latest'; +import { rawNotificationSchema } from '../../../saved_objects/scheduled_report/schemas/v1'; +import { + ScheduledReportApiJSON, + ScheduledReportType, + ScheduledReportingJobResponse, +} from '../../../types'; +import { SCHEDULED_REPORT_SAVED_OBJECT_TYPE } from '../../../saved_objects'; +import { RequestHandler, RequestParams } from './request_handler'; +import { + transformRawScheduledReportToReport, + transformRawScheduledReportToTaskParams, +} from './lib'; +import { ScheduledReportAuditAction, scheduledReportAuditEvent } from '../audit_events'; + +// Using the limit specified in the cloud email service limits +// https://www.elastic.co/docs/explore-analyze/alerts-cases/watcher/enable-watcher#cloud-email-service-limits +const MAX_ALLOWED_EMAILS = 30; + +const validation = { + params: schema.object({ exportType: schema.string({ minLength: 2 }) }), + body: schema.object({ + schedule: scheduleRruleSchema, + notification: schema.maybe(rawNotificationSchema), + jobParams: schema.string(), + }), + query: schema.nullable(schema.object({})), +}; + +/** + * Handles the common parts of requests to generate a report + * Serves report job handling in the context of the request to generate the report + */ +export class ScheduleRequestHandler extends RequestHandler< + (typeof validation)['params'], + (typeof validation)['query'], + (typeof validation)['body'], + ScheduledReportApiJSON +> { + protected async checkLicenseAndTimezone( + exportTypeId: string, + browserTimezone: string + ): Promise { + const { reporting, res } = this.opts; + const licenseInfo = await reporting.getLicenseInfo(); + const licenseResults = licenseInfo.scheduledReports; + + if (!licenseResults.enableLinks) { + return res.forbidden({ body: licenseResults.message }); + } + return super.checkLicenseAndTimezone(exportTypeId, browserTimezone); + } + + public static getValidation() { + return validation; + } + + public getSchedule(): RruleSchedule { + let rruleDef: null | RruleSchedule['rrule'] = null; + const req = this.opts.req; + const res = this.opts.res; + + const { schedule } = req.body; + const { rrule } = schedule ?? {}; + rruleDef = rrule; + + if (isEmpty(rruleDef)) { + throw res.customError({ + statusCode: 400, + body: 'A schedule is required to create a scheduled report.', + }); + } + + return schedule; + } + + public getNotification(): RawNotification | undefined { + const { reporting, req, res } = this.opts; + + const { notification } = req.body; + if (isEmpty(notification) || isEmpty(notification.email)) { + return undefined; + } + + const allEmails = new Set([ + ...(notification.email.to || []), + ...(notification.email.bcc || []), + ...(notification.email.cc || []), + ]); + + if (allEmails.size === 0) { + return undefined; + } + + if (allEmails.size > MAX_ALLOWED_EMAILS) { + throw res.customError({ + statusCode: 400, + body: `Maximum number of recipients exceeded: cannot specify more than ${MAX_ALLOWED_EMAILS} recipients.`, + }); + } + + const invalidEmails = reporting.validateNotificationEmails([...allEmails]); + if (invalidEmails) { + throw res.customError({ + statusCode: 400, + body: `Invalid email address(es): ${invalidEmails}`, + }); + } + + return notification; + } + + public async enqueueJob(params: RequestParams) { + const { id, exportTypeId, jobParams, schedule, notification } = params; + const { reporting, logger, req, user } = this.opts; + + const soClient = await reporting.getScopedSoClient(req); + const auditLogger = await reporting.getAuditLogger(req); + const { version, job, jobType, name } = await this.createJob(exportTypeId, jobParams); + + const reportId = id || SavedObjectsUtils.generateId(); + auditLogger.log( + scheduledReportAuditEvent({ + action: ScheduledReportAuditAction.SCHEDULE, + savedObject: { type: SCHEDULED_REPORT_SAVED_OBJECT_TYPE, id: reportId, name: job.title }, + outcome: 'unknown', + }) + ); + + const payload = { + ...job, + title: job.title, + objectType: jobParams.objectType, + browserTimezone: jobParams.browserTimezone, + version, + spaceId: reporting.getSpaceId(req, logger), + }; + + // TODO - extract saved object references before persisting + + const attributes = { + createdAt: moment.utc().toISOString(), + // we've already checked that user exists in handleRequest + // this fallback is just to satisfy the type + createdBy: user ? user.username : 'unknown', + enabled: true, + jobType, + meta: { + // telemetry fields + isDeprecated: job.isDeprecated, + layout: jobParams.layout?.id, + objectType: jobParams.objectType, + }, + migrationVersion: version, + ...(notification ? { notification } : {}), + title: job.title, + payload: JSON.stringify(omit(payload, 'forceNow')), + schedule: schedule!, + }; + + // Create a scheduled_report saved object + const report = await soClient.create( + SCHEDULED_REPORT_SAVED_OBJECT_TYPE, + attributes, + { id: reportId } + ); + logger.debug(`Successfully created scheduled report: ${report.id}`); + + // Schedule the report with Task Manager + const task = await reporting.scheduleRecurringTask( + req, + transformRawScheduledReportToTaskParams(report) + ); + logger.info( + `Scheduled "${name}" reporting task. Task ID: task:${task.id}. Report ID: ${report.id}` + ); + + return transformRawScheduledReportToReport(report); + } + + public async handleRequest(params: RequestParams) { + const { exportTypeId, jobParams } = params; + const { reporting, req, res } = this.opts; + + const checkErrorResponse = await this.checkLicenseAndTimezone( + exportTypeId, + jobParams.browserTimezone + ); + if (checkErrorResponse) { + return checkErrorResponse; + } + + // check that security requirements are met + const reportingHealth = await reporting.getHealthInfo(); + if (!reportingHealth.hasPermanentEncryptionKey) { + return res.forbidden({ + body: `Permanent encryption key must be set for scheduled reporting`, + }); + } + if (!reportingHealth.isSufficientlySecure) { + return res.forbidden({ + body: `Security and API keys must be enabled for scheduled reporting`, + }); + } + // check that username exists + if (!this.opts.user || !this.opts.user.username) { + return res.forbidden({ + body: `User must be authenticated to schedule a report`, + }); + } + + const auditLogger = await reporting.getAuditLogger(req); + + let report: ScheduledReportApiJSON | undefined; + const id = SavedObjectsUtils.generateId(); + try { + report = await this.enqueueJob({ ...params, id }); + return res.ok({ + headers: { 'content-type': 'application/json' }, + body: { + job: report, + }, + }); + } catch (err) { + auditLogger.log( + scheduledReportAuditEvent({ + action: ScheduledReportAuditAction.SCHEDULE, + savedObject: { type: SCHEDULED_REPORT_SAVED_OBJECT_TYPE, id, name: jobParams.title }, + error: err, + }) + ); + return this.handleError(err, undefined, report?.jobtype); + } + } +} diff --git a/x-pack/platform/plugins/private/reporting/server/routes/common/generate/index.ts b/x-pack/platform/plugins/private/reporting/server/routes/common/scheduled/index.ts similarity index 78% rename from x-pack/platform/plugins/private/reporting/server/routes/common/generate/index.ts rename to x-pack/platform/plugins/private/reporting/server/routes/common/scheduled/index.ts index a16ddf1204b8..61acdbbbb77a 100644 --- a/x-pack/platform/plugins/private/reporting/server/routes/common/generate/index.ts +++ b/x-pack/platform/plugins/private/reporting/server/routes/common/scheduled/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { handleUnavailable, RequestHandler } from './request_handler'; +export { scheduledQueryFactory } from './scheduled_query'; diff --git a/x-pack/platform/plugins/private/reporting/server/routes/common/scheduled/scheduled_query.test.ts b/x-pack/platform/plugins/private/reporting/server/routes/common/scheduled/scheduled_query.test.ts new file mode 100644 index 000000000000..e553bdee2ecf --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/routes/common/scheduled/scheduled_query.test.ts @@ -0,0 +1,1378 @@ +/* + * 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 { + AuditLogger, + ElasticsearchClient, + KibanaRequest, + KibanaResponseFactory, + SavedObject, + SavedObjectsClientContract, + SavedObjectsFindResponse, +} from '@kbn/core/server'; +import { + elasticsearchServiceMock, + httpServerMock, + loggingSystemMock, +} from '@kbn/core/server/mocks'; +import { createMockConfigSchema } from '@kbn/reporting-mocks-server'; +import { createMockReportingCore } from '../../../test_helpers'; +import { + transformResponse, + scheduledQueryFactory, + CreatedAtSearchResponse, +} from './scheduled_query'; +import { ReportingCore } from '../../..'; +import { ScheduledReportType } from '../../../types'; +import { TaskManagerStartContract } from '@kbn/task-manager-plugin/server'; +import { omit } from 'lodash'; + +const fakeRawRequest = { + headers: { + authorization: `ApiKey skdjtq4u543yt3rhewrh`, + }, + path: '/', +} as unknown as KibanaRequest; + +const getMockResponseFactory = () => + ({ + ...httpServerMock.createResponseFactory(), + forbidden: (obj: unknown) => obj, + unauthorized: (obj: unknown) => obj, + customError: (err: unknown) => err, + } as unknown as KibanaResponseFactory); + +const payload = + '{"browserTimezone":"America/New_York","layout":{"dimensions":{"height":2220,"width":1364},"id":"preserve_layout"},"objectType":"dashboard","title":"[Logs] Web Traffic","version":"9.1.0","locatorParams":[{"id":"DASHBOARD_APP_LOCATOR","params":{"dashboardId":"edf84fe0-e1a0-11e7-b6d5-4dc382ef7f5b","preserveSavedFilters":true,"timeRange":{"from":"now-7d/d","to":"now"},"useHash":false,"viewMode":"view"}}],"isDeprecated":false}'; +const jsonPayload = JSON.parse(payload); +const savedObjects: Array> = [ + { + type: 'scheduled_report', + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + namespaces: ['a-space'], + attributes: { + createdAt: '2025-05-06T21:10:17.137Z', + createdBy: 'elastic', + enabled: true, + jobType: 'printable_pdf_v2', + meta: { + isDeprecated: false, + layout: 'preserve_layout', + objectType: 'dashboard', + }, + migrationVersion: '9.1.0', + title: '[Logs] Web Traffic', + payload, + schedule: { + rrule: { + freq: 3, + interval: 3, + byhour: [12], + byminute: [0], + tzid: 'UTC', + }, + }, + }, + references: [], + managed: false, + updated_at: '2025-05-06T21:10:17.137Z', + created_at: '2025-05-06T21:10:17.137Z', + version: 'WzEsMV0=', + coreMigrationVersion: '8.8.0', + typeMigrationVersion: '10.1.0', + }, + { + type: 'scheduled_report', + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + namespaces: ['a-space'], + attributes: { + createdAt: '2025-05-06T21:12:06.584Z', + createdBy: 'not-elastic', + enabled: true, + jobType: 'PNGV2', + meta: { + isDeprecated: false, + layout: 'preserve_layout', + objectType: 'dashboard', + }, + migrationVersion: '9.1.0', + notification: { + email: { + to: ['user@elastic.co'], + }, + }, + title: 'Another cool dashboard', + payload: + '{"browserTimezone":"America/New_York","layout":{"dimensions":{"height":2220,"width":1364},"id":"preserve_layout"},"objectType":"dashboard","title":"[Logs] Web Traffic","version":"9.1.0","locatorParams":[{"id":"DASHBOARD_APP_LOCATOR","params":{"dashboardId":"edf84fe0-e1a0-11e7-b6d5-4dc382ef7f5b","preserveSavedFilters":true,"timeRange":{"from":"now-7d/d","to":"now"},"useHash":false,"viewMode":"view"}}],"isDeprecated":false}', + schedule: { + rrule: { + freq: 1, + interval: 3, + tzid: 'UTC', + }, + }, + }, + references: [], + managed: false, + updated_at: '2025-05-06T21:12:06.584Z', + created_at: '2025-05-06T21:12:06.584Z', + version: 'WzIsMV0=', + coreMigrationVersion: '8.8.0', + typeMigrationVersion: '10.1.0', + }, +]; +const soResponse: SavedObjectsFindResponse = { + page: 1, + per_page: 10, + total: 2, + saved_objects: savedObjects.map((so) => ({ ...so, score: 0 })), +}; + +const lastRunResponse: CreatedAtSearchResponse = { + took: 1, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + hits: { + total: { value: 2, relation: 'eq' }, + max_score: null, + hits: [ + { + _index: '.ds-.kibana-reporting-2025.05.06-000001', + _id: '7c14d3e0-5d3f-4374-87f8-1758d2aaa10b', + _score: null, + _source: { + created_at: '2025-05-06T21:12:07.198Z', + }, + fields: { + scheduled_report_id: ['2da1cb75-04c7-4202-a9f0-f8bcce63b0f4'], + }, + sort: [1746565930198], + }, + { + _index: '.ds-.kibana-reporting-2025.05.06-000001', + _id: '895f9620-cf3c-4e9e-9bf2-3750360ebd81', + _score: null, + _source: { + created_at: '2025-05-06T12:00:00.500Z', + }, + fields: { + scheduled_report_id: ['aa8b6fb3-cf61-4903-bce3-eec9ddc823ca'], + }, + sort: [1746565930198], + }, + ], + }, +}; + +const mockLogger = loggingSystemMock.createLogger(); + +describe('scheduledQueryFactory', () => { + let client: ReturnType; + let core: ReportingCore; + let auditLogger: AuditLogger; + let soClient: SavedObjectsClientContract; + let taskManager: TaskManagerStartContract; + let scheduledQuery: ReturnType; + let mockResponseFactory: ReturnType; + + beforeEach(async () => { + jest.clearAllMocks(); + const schema = createMockConfigSchema(); + core = await createMockReportingCore(schema); + + auditLogger = await core.getAuditLogger(fakeRawRequest); + auditLogger.log = jest.fn(); + + soClient = await core.getScopedSoClient(fakeRawRequest); + soClient.find = jest.fn().mockImplementation(async () => { + return soResponse; + }); + soClient.bulkGet = jest.fn().mockImplementation(async () => ({ saved_objects: savedObjects })); + soClient.bulkUpdate = jest.fn().mockImplementation(async () => ({ + saved_objects: savedObjects.map((so) => ({ + id: so.id, + type: so.type, + attributes: { enabled: false }, + })), + })); + client = (await core.getEsClient()).asInternalUser as typeof client; + client.search.mockResponse( + lastRunResponse as unknown as Awaited> + ); + taskManager = await core.getTaskManager(); + taskManager.bulkDisable = jest.fn().mockImplementation(async () => ({ + tasks: savedObjects.map((so) => ({ id: so.id })), + errors: [], + })); + scheduledQuery = scheduledQueryFactory(core); + jest.spyOn(core, 'canManageReportingForSpace').mockResolvedValue(true); + + mockResponseFactory = getMockResponseFactory(); + (mockResponseFactory.ok as jest.Mock) = jest.fn((args: unknown) => args); + (mockResponseFactory.forbidden as jest.Mock) = jest.fn((args: unknown) => args); + (mockResponseFactory.badRequest as jest.Mock) = jest.fn((args: unknown) => args); + }); + + describe('list', () => { + it('should pass parameters in the request body', async () => { + const result = await scheduledQuery.list( + mockLogger, + fakeRawRequest, + mockResponseFactory, + { username: 'somebody' }, + 1, + 10 + ); + + expect(soClient.find).toHaveBeenCalledTimes(1); + expect(soClient.find).toHaveBeenCalledWith({ + type: 'scheduled_report', + page: 1, + perPage: 10, + }); + expect(client.search).toHaveBeenCalledTimes(1); + expect(client.search).toHaveBeenCalledWith({ + _source: ['created_at'], + collapse: { field: 'scheduled_report_id' }, + index: '.reporting-*,.kibana-reporting*', + query: { + bool: { + filter: [ + { + terms: { + scheduled_report_id: [ + 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + ], + }, + }, + ], + }, + }, + size: 10, + sort: [{ created_at: { order: 'desc' } }], + }); + + expect(auditLogger.log).toHaveBeenCalledTimes(2); + expect(auditLogger.log).toHaveBeenNthCalledWith(1, { + event: { + action: 'scheduled_report_list', + category: ['database'], + outcome: 'success', + type: ['access'], + }, + kibana: { + saved_object: { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + type: 'scheduled_report', + name: '[Logs] Web Traffic', + }, + }, + message: + 'User has accessed scheduled report [id=aa8b6fb3-cf61-4903-bce3-eec9ddc823ca] [name=[Logs] Web Traffic]', + }); + + expect(auditLogger.log).toHaveBeenNthCalledWith(2, { + event: { + action: 'scheduled_report_list', + category: ['database'], + outcome: 'success', + type: ['access'], + }, + kibana: { + saved_object: { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + type: 'scheduled_report', + name: 'Another cool dashboard', + }, + }, + message: + 'User has accessed scheduled report [id=2da1cb75-04c7-4202-a9f0-f8bcce63b0f4] [name=Another cool dashboard]', + }); + + expect(result).toEqual({ + page: 1, + per_page: 10, + total: 2, + data: [ + { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + created_at: '2025-05-06T21:10:17.137Z', + created_by: 'elastic', + enabled: true, + jobtype: 'printable_pdf_v2', + last_run: '2025-05-06T12:00:00.500Z', + next_run: expect.any(String), + payload: jsonPayload, + schedule: { + rrule: { + freq: 3, + interval: 3, + byhour: [12], + byminute: [0], + tzid: 'UTC', + }, + }, + space_id: 'a-space', + title: '[Logs] Web Traffic', + }, + { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + created_at: '2025-05-06T21:12:06.584Z', + created_by: 'not-elastic', + enabled: true, + jobtype: 'PNGV2', + last_run: '2025-05-06T21:12:07.198Z', + next_run: expect.any(String), + notification: { + email: { + to: ['user@elastic.co'], + }, + }, + payload: jsonPayload, + space_id: 'a-space', + title: 'Another cool dashboard', + schedule: { + rrule: { + freq: 1, + interval: 3, + tzid: 'UTC', + }, + }, + }, + ], + }); + }); + + it('should filter by username when user does not have manage reporting permissions', async () => { + jest.spyOn(core, 'canManageReportingForSpace').mockResolvedValueOnce(false); + await scheduledQuery.list( + mockLogger, + fakeRawRequest, + mockResponseFactory, + { username: 'somebody' }, + 1, + 10 + ); + + expect(soClient.find).toHaveBeenCalledTimes(1); + expect(soClient.find).toHaveBeenCalledWith({ + type: 'scheduled_report', + page: 1, + perPage: 10, + filter: 'scheduled_report.attributes.createdBy: "somebody"', + }); + expect(client.search).toHaveBeenCalledTimes(1); + expect(client.search).toHaveBeenCalledWith({ + _source: ['created_at'], + collapse: { field: 'scheduled_report_id' }, + index: '.reporting-*,.kibana-reporting*', + query: { + bool: { + filter: [ + { + terms: { + scheduled_report_id: [ + 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + ], + }, + }, + ], + }, + }, + size: 10, + sort: [{ created_at: { order: 'desc' } }], + }); + }); + + it('should return an empty array when there are no hits', async () => { + soClient.find = jest.fn().mockImplementationOnce(async () => ({ + page: 1, + per_page: 10, + total: 0, + saved_objects: [], + })); + const result = await scheduledQuery.list( + mockLogger, + fakeRawRequest, + mockResponseFactory, + { username: 'somebody' }, + 1, + 10 + ); + expect(soClient.find).toHaveBeenCalledTimes(1); + expect(soClient.find).toHaveBeenCalledWith({ + type: 'scheduled_report', + page: 1, + perPage: 10, + }); + expect(client.search).not.toHaveBeenCalled(); + expect(result).toEqual({ page: 1, per_page: 10, total: 0, data: [] }); + }); + + it('should reject if the soClient.find throws an error', async () => { + soClient.find = jest.fn().mockImplementationOnce(async () => { + throw new Error('Some error'); + }); + + await expect( + scheduledQuery.list( + mockLogger, + fakeRawRequest, + mockResponseFactory, + { username: 'somebody' }, + 1, + 10 + ) + ).rejects.toMatchInlineSnapshot(` + Object { + "body": "Error listing scheduled reports: Some error", + "statusCode": 500, + } + `); + }); + + it('should gracefully handle esClient.search errors', async () => { + client.search.mockImplementationOnce(async () => { + throw new Error('Some other error'); + }); + + const result = await scheduledQuery.list( + mockLogger, + fakeRawRequest, + mockResponseFactory, + { username: 'somebody' }, + 1, + 10 + ); + + expect(result).toEqual({ + page: 1, + per_page: 10, + total: 2, + data: [ + { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + created_at: '2025-05-06T21:10:17.137Z', + created_by: 'elastic', + enabled: true, + jobtype: 'printable_pdf_v2', + next_run: expect.any(String), + payload: jsonPayload, + schedule: { + rrule: { + freq: 3, + interval: 3, + byhour: [12], + byminute: [0], + tzid: 'UTC', + }, + }, + space_id: 'a-space', + title: '[Logs] Web Traffic', + }, + { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + created_at: '2025-05-06T21:12:06.584Z', + created_by: 'not-elastic', + enabled: true, + jobtype: 'PNGV2', + next_run: expect.any(String), + notification: { + email: { + to: ['user@elastic.co'], + }, + }, + payload: jsonPayload, + title: 'Another cool dashboard', + schedule: { + rrule: { + freq: 1, + interval: 3, + tzid: 'UTC', + }, + }, + space_id: 'a-space', + }, + ], + }); + + expect(mockLogger.warn).toHaveBeenCalledWith( + `Error getting last run for scheduled reports: Some other error` + ); + }); + }); + + describe('bulkDisable', () => { + it('should pass parameters in the request body', async () => { + const result = await scheduledQuery.bulkDisable( + mockLogger, + fakeRawRequest, + mockResponseFactory, + ['aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4'], + { username: 'somebody' } + ); + + expect(soClient.bulkGet).toHaveBeenCalledTimes(1); + expect(soClient.bulkGet).toHaveBeenCalledWith([ + { id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', type: 'scheduled_report' }, + { id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', type: 'scheduled_report' }, + ]); + expect(soClient.bulkUpdate).toHaveBeenCalledTimes(1); + expect(soClient.bulkUpdate).toHaveBeenCalledWith([ + { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + type: 'scheduled_report', + attributes: { enabled: false }, + }, + { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + type: 'scheduled_report', + attributes: { enabled: false }, + }, + ]); + expect(taskManager.bulkDisable).toHaveBeenCalledTimes(1); + expect(taskManager.bulkDisable).toHaveBeenCalledWith([ + 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + ]); + + expect(auditLogger.log).toHaveBeenCalledTimes(2); + expect(auditLogger.log).toHaveBeenNthCalledWith(1, { + event: { + action: 'scheduled_report_disable', + category: ['database'], + outcome: 'unknown', + type: ['change'], + }, + kibana: { + saved_object: { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + name: '[Logs] Web Traffic', + type: 'scheduled_report', + }, + }, + message: + 'User is disabling scheduled report [id=aa8b6fb3-cf61-4903-bce3-eec9ddc823ca] [name=[Logs] Web Traffic]', + }); + + expect(auditLogger.log).toHaveBeenNthCalledWith(2, { + event: { + action: 'scheduled_report_disable', + category: ['database'], + outcome: 'unknown', + type: ['change'], + }, + kibana: { + saved_object: { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + name: 'Another cool dashboard', + type: 'scheduled_report', + }, + }, + message: + 'User is disabling scheduled report [id=2da1cb75-04c7-4202-a9f0-f8bcce63b0f4] [name=Another cool dashboard]', + }); + + expect(result).toEqual({ + scheduled_report_ids: [ + 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + ], + errors: [], + total: 2, + }); + }); + + it('should not disable scheduled report when user does not have permissions', async () => { + jest.spyOn(core, 'canManageReportingForSpace').mockResolvedValueOnce(false); + soClient.bulkUpdate = jest.fn().mockImplementationOnce(async () => ({ + saved_objects: [ + { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + type: 'scheduled_report', + attributes: { enabled: false }, + }, + ], + })); + taskManager.bulkDisable = jest.fn().mockImplementationOnce(async () => ({ + tasks: [{ id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca' }], + errors: [], + })); + const result = await scheduledQuery.bulkDisable( + mockLogger, + fakeRawRequest, + mockResponseFactory, + ['aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4'], + { username: 'elastic' } + ); + + expect(soClient.bulkGet).toHaveBeenCalledTimes(1); + expect(soClient.bulkGet).toHaveBeenCalledWith([ + { id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', type: 'scheduled_report' }, + { id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', type: 'scheduled_report' }, + ]); + expect(soClient.bulkUpdate).toHaveBeenCalledTimes(1); + expect(soClient.bulkUpdate).toHaveBeenCalledWith([ + { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + type: 'scheduled_report', + attributes: { enabled: false }, + }, + ]); + expect(taskManager.bulkDisable).toHaveBeenCalledTimes(1); + expect(taskManager.bulkDisable).toHaveBeenCalledWith([ + 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + ]); + + expect(result).toEqual({ + scheduled_report_ids: ['aa8b6fb3-cf61-4903-bce3-eec9ddc823ca'], + errors: [ + { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + message: `Not found.`, + status: 404, + }, + ], + total: 2, + }); + expect(mockLogger.warn).toHaveBeenCalledWith( + `User "elastic" attempted to disable scheduled report "2da1cb75-04c7-4202-a9f0-f8bcce63b0f4" created by "not-elastic" without sufficient privileges.` + ); + + expect(auditLogger.log).toHaveBeenCalledTimes(2); + expect(auditLogger.log).toHaveBeenNthCalledWith(1, { + event: { + action: 'scheduled_report_disable', + category: ['database'], + outcome: 'unknown', + type: ['change'], + }, + kibana: { + saved_object: { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + name: '[Logs] Web Traffic', + type: 'scheduled_report', + }, + }, + message: + 'User is disabling scheduled report [id=aa8b6fb3-cf61-4903-bce3-eec9ddc823ca] [name=[Logs] Web Traffic]', + }); + expect(auditLogger.log).toHaveBeenNthCalledWith(2, { + error: { + code: 'Error', + message: 'Not found.', + }, + event: { + action: 'scheduled_report_disable', + category: ['database'], + outcome: 'failure', + type: ['change'], + }, + kibana: { + saved_object: { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + type: 'scheduled_report', + name: 'Another cool dashboard', + }, + }, + message: + 'Failed attempt to disable scheduled report [id=2da1cb75-04c7-4202-a9f0-f8bcce63b0f4] [name=Another cool dashboard]', + }); + }); + + it('should handle errors in bulk get', async () => { + soClient.bulkGet = jest.fn().mockImplementationOnce(async () => ({ + saved_objects: [ + { + id: savedObjects[0].id, + type: savedObjects[0].type, + error: { + error: 'Not Found', + message: + 'Saved object [scheduled-report/aa8b6fb3-cf61-4903-bce3-eec9ddc823ca] not found', + statusCode: 404, + }, + }, + savedObjects[1], + ], + })); + soClient.bulkUpdate = jest.fn().mockImplementation(async () => ({ + saved_objects: [ + { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + type: 'scheduled_report', + attributes: { enabled: false }, + }, + ], + })); + taskManager.bulkDisable = jest.fn().mockImplementation(async () => ({ + tasks: [{ id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4' }], + errors: [], + })); + const result = await scheduledQuery.bulkDisable( + mockLogger, + fakeRawRequest, + mockResponseFactory, + ['aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4'], + { username: 'elastic' } + ); + + expect(soClient.bulkGet).toHaveBeenCalledTimes(1); + expect(soClient.bulkGet).toHaveBeenCalledWith([ + { id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', type: 'scheduled_report' }, + { id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', type: 'scheduled_report' }, + ]); + expect(soClient.bulkUpdate).toHaveBeenCalledTimes(1); + expect(soClient.bulkUpdate).toHaveBeenCalledWith([ + { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + type: 'scheduled_report', + attributes: { enabled: false }, + }, + ]); + expect(taskManager.bulkDisable).toHaveBeenCalledTimes(1); + expect(taskManager.bulkDisable).toHaveBeenCalledWith([ + '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + ]); + + expect(auditLogger.log).toHaveBeenCalledTimes(1); + expect(auditLogger.log).toHaveBeenNthCalledWith(1, { + event: { + action: 'scheduled_report_disable', + category: ['database'], + outcome: 'unknown', + type: ['change'], + }, + kibana: { + saved_object: { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + name: 'Another cool dashboard', + type: 'scheduled_report', + }, + }, + message: + 'User is disabling scheduled report [id=2da1cb75-04c7-4202-a9f0-f8bcce63b0f4] [name=Another cool dashboard]', + }); + + expect(result).toEqual({ + scheduled_report_ids: ['2da1cb75-04c7-4202-a9f0-f8bcce63b0f4'], + errors: [ + { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + message: + 'Saved object [scheduled-report/aa8b6fb3-cf61-4903-bce3-eec9ddc823ca] not found', + status: 404, + }, + ], + total: 2, + }); + }); + + it('should short-circuit if no saved objects to update', async () => { + soClient.bulkGet = jest.fn().mockImplementationOnce(async () => ({ + saved_objects: [ + { + id: savedObjects[0].id, + type: savedObjects[0].type, + error: { + error: 'Not found', + message: + 'Saved object [scheduled-report/aa8b6fb3-cf61-4903-bce3-eec9ddc823ca] not found', + statusCode: 404, + }, + }, + { + id: savedObjects[1].id, + type: savedObjects[1].type, + error: { error: 'Bad Request', message: 'Some unspecified error', statusCode: 404 }, + }, + ], + })); + const result = await scheduledQuery.bulkDisable( + mockLogger, + fakeRawRequest, + mockResponseFactory, + ['aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4'], + { username: 'elastic' } + ); + + expect(soClient.bulkGet).toHaveBeenCalledTimes(1); + expect(soClient.bulkGet).toHaveBeenCalledWith([ + { id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', type: 'scheduled_report' }, + { id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', type: 'scheduled_report' }, + ]); + expect(soClient.bulkUpdate).not.toHaveBeenCalled(); + expect(auditLogger.log).not.toHaveBeenCalled(); + expect(taskManager.bulkDisable).not.toHaveBeenCalled(); + expect(result).toEqual({ + scheduled_report_ids: [], + errors: [ + { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + message: + 'Saved object [scheduled-report/aa8b6fb3-cf61-4903-bce3-eec9ddc823ca] not found', + status: 404, + }, + { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + message: 'Some unspecified error', + status: 404, + }, + ], + total: 2, + }); + }); + + it('should not update saved object if already disabled', async () => { + soClient.bulkGet = jest.fn().mockImplementationOnce(async () => ({ + saved_objects: [ + { + id: savedObjects[0].id, + type: savedObjects[0].type, + attributes: { ...savedObjects[0].attributes, enabled: false }, + }, + savedObjects[1], + ], + })); + soClient.bulkUpdate = jest.fn().mockImplementation(async () => ({ + saved_objects: [ + { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + type: 'scheduled_report', + attributes: { enabled: false }, + }, + ], + })); + const result = await scheduledQuery.bulkDisable( + mockLogger, + fakeRawRequest, + mockResponseFactory, + ['aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4'], + { username: 'somebody' } + ); + + expect(soClient.bulkGet).toHaveBeenCalledTimes(1); + expect(soClient.bulkGet).toHaveBeenCalledWith([ + { id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', type: 'scheduled_report' }, + { id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', type: 'scheduled_report' }, + ]); + expect(soClient.bulkUpdate).toHaveBeenCalledTimes(1); + expect(soClient.bulkUpdate).toHaveBeenCalledWith([ + { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + type: 'scheduled_report', + attributes: { enabled: false }, + }, + ]); + expect(mockLogger.debug).toHaveBeenCalledWith( + `Scheduled report aa8b6fb3-cf61-4903-bce3-eec9ddc823ca is already disabled` + ); + expect(taskManager.bulkDisable).toHaveBeenCalledTimes(1); + // TM still called with both in case the task was not disabled + expect(taskManager.bulkDisable).toHaveBeenCalledWith([ + '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + ]); + + expect(auditLogger.log).toHaveBeenCalledTimes(1); + expect(auditLogger.log).toHaveBeenNthCalledWith(1, { + event: { + action: 'scheduled_report_disable', + category: ['database'], + outcome: 'unknown', + type: ['change'], + }, + kibana: { + saved_object: { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + name: 'Another cool dashboard', + type: 'scheduled_report', + }, + }, + message: + 'User is disabling scheduled report [id=2da1cb75-04c7-4202-a9f0-f8bcce63b0f4] [name=Another cool dashboard]', + }); + + expect(result).toEqual({ + scheduled_report_ids: [ + 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + ], + errors: [], + total: 2, + }); + }); + + it('should handle errors in bulk update', async () => { + soClient.bulkUpdate = jest.fn().mockImplementation(async () => ({ + saved_objects: [ + { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + type: 'scheduled_report', + attributes: { enabled: false }, + }, + { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + type: 'scheduled_report', + error: { error: 'Conflict', message: 'Error updating saved object', statusCode: 409 }, + }, + ], + })); + taskManager.bulkDisable = jest.fn().mockImplementation(async () => ({ + tasks: [{ id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4' }], + errors: [], + })); + const result = await scheduledQuery.bulkDisable( + mockLogger, + fakeRawRequest, + mockResponseFactory, + ['aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4'], + { username: 'elastic' } + ); + + expect(soClient.bulkGet).toHaveBeenCalledTimes(1); + expect(soClient.bulkGet).toHaveBeenCalledWith([ + { id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', type: 'scheduled_report' }, + { id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', type: 'scheduled_report' }, + ]); + expect(soClient.bulkUpdate).toHaveBeenCalledTimes(1); + expect(soClient.bulkUpdate).toHaveBeenCalledWith([ + { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + type: 'scheduled_report', + attributes: { enabled: false }, + }, + { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + type: 'scheduled_report', + attributes: { enabled: false }, + }, + ]); + expect(taskManager.bulkDisable).toHaveBeenCalledTimes(1); + expect(taskManager.bulkDisable).toHaveBeenCalledWith([ + '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + ]); + + expect(auditLogger.log).toHaveBeenCalledTimes(3); + expect(auditLogger.log).toHaveBeenNthCalledWith(3, { + error: { + code: 'Error', + message: 'Error updating saved object', + }, + event: { + action: 'scheduled_report_disable', + category: ['database'], + outcome: 'failure', + type: ['change'], + }, + kibana: { + saved_object: { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + type: 'scheduled_report', + }, + }, + message: + 'Failed attempt to disable scheduled report [id=aa8b6fb3-cf61-4903-bce3-eec9ddc823ca]', + }); + + expect(result).toEqual({ + scheduled_report_ids: ['2da1cb75-04c7-4202-a9f0-f8bcce63b0f4'], + errors: [ + { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + message: 'Error updating saved object', + status: 409, + }, + ], + total: 2, + }); + }); + + it('should handle errors in bulk disable', async () => { + taskManager.bulkDisable = jest.fn().mockImplementation(async () => ({ + tasks: [{ id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4' }], + errors: [ + { + type: 'task', + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + error: { + statusCode: 400, + error: 'Fail', + message: 'Error disabling task', + }, + }, + ], + })); + const result = await scheduledQuery.bulkDisable( + mockLogger, + fakeRawRequest, + mockResponseFactory, + ['aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4'], + { username: 'elastic' } + ); + + expect(soClient.bulkGet).toHaveBeenCalledTimes(1); + expect(soClient.bulkGet).toHaveBeenCalledWith([ + { id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', type: 'scheduled_report' }, + { id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', type: 'scheduled_report' }, + ]); + expect(soClient.bulkUpdate).toHaveBeenCalledTimes(1); + expect(soClient.bulkUpdate).toHaveBeenCalledWith([ + { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + type: 'scheduled_report', + attributes: { enabled: false }, + }, + { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + type: 'scheduled_report', + attributes: { enabled: false }, + }, + ]); + expect(taskManager.bulkDisable).toHaveBeenCalledTimes(1); + expect(taskManager.bulkDisable).toHaveBeenCalledWith([ + 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + ]); + + expect(result).toEqual({ + scheduled_report_ids: ['2da1cb75-04c7-4202-a9f0-f8bcce63b0f4'], + errors: [ + { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + message: + 'Scheduled report disabled but task disabling failed due to: Error disabling task', + status: 400, + }, + ], + total: 2, + }); + }); + + it('should reject if the soClient throws an error', async () => { + soClient.bulkGet = jest.fn().mockImplementationOnce(async () => { + throw new Error('Some error'); + }); + + await expect( + scheduledQuery.bulkDisable( + mockLogger, + fakeRawRequest, + mockResponseFactory, + ['aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4'], + { username: 'somebody' } + ) + ).rejects.toMatchInlineSnapshot(` + Object { + "body": "Error disabling scheduled reports: Some error", + "statusCode": 500, + } + `); + }); + }); +}); + +describe('transformResponse', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should correctly transform the responses', () => { + expect(transformResponse(mockLogger, soResponse, lastRunResponse)).toEqual({ + page: 1, + per_page: 10, + total: 2, + data: [ + { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + created_at: '2025-05-06T21:10:17.137Z', + created_by: 'elastic', + enabled: true, + jobtype: 'printable_pdf_v2', + last_run: '2025-05-06T12:00:00.500Z', + next_run: expect.any(String), + payload: jsonPayload, + schedule: { + rrule: { + freq: 3, + interval: 3, + byhour: [12], + byminute: [0], + tzid: 'UTC', + }, + }, + space_id: 'a-space', + title: '[Logs] Web Traffic', + }, + { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + created_at: '2025-05-06T21:12:06.584Z', + created_by: 'not-elastic', + enabled: true, + jobtype: 'PNGV2', + last_run: '2025-05-06T21:12:07.198Z', + next_run: expect.any(String), + notification: { + email: { + to: ['user@elastic.co'], + }, + }, + payload: jsonPayload, + title: 'Another cool dashboard', + schedule: { + rrule: { + freq: 1, + interval: 3, + tzid: 'UTC', + }, + }, + space_id: 'a-space', + }, + ], + }); + }); + + it('handles malformed payload', () => { + const malformedSo = { + ...savedObjects[0], + attributes: { + ...savedObjects[0].attributes, + payload: 'not a valid JSON', + }, + score: 0, + }; + expect( + transformResponse( + mockLogger, + { + page: 1, + per_page: 10, + total: 1, + saved_objects: [malformedSo], + }, + lastRunResponse + ) + ).toEqual({ + page: 1, + per_page: 10, + total: 1, + data: [ + { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + created_at: '2025-05-06T21:10:17.137Z', + created_by: 'elastic', + enabled: true, + jobtype: 'printable_pdf_v2', + last_run: '2025-05-06T12:00:00.500Z', + next_run: expect.any(String), + payload: undefined, + schedule: { + rrule: { + freq: 3, + interval: 3, + byhour: [12], + byminute: [0], + tzid: 'UTC', + }, + }, + space_id: 'a-space', + title: '[Logs] Web Traffic', + }, + ], + }); + expect(mockLogger.warn).toHaveBeenCalledWith( + `Failed to parse payload for scheduled report aa8b6fb3-cf61-4903-bce3-eec9ddc823ca: Unexpected token 'o', \"not a valid JSON\" is not valid JSON` + ); + }); + + it('handles missing last run response', () => { + const thisLastRunResponse: CreatedAtSearchResponse = { + ...lastRunResponse, + hits: { + total: { value: 1, relation: 'eq' }, + hits: [lastRunResponse.hits.hits[0]], + }, + }; + + expect(transformResponse(mockLogger, soResponse, thisLastRunResponse)).toEqual({ + page: 1, + per_page: 10, + total: 2, + data: [ + { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + created_at: '2025-05-06T21:10:17.137Z', + created_by: 'elastic', + enabled: true, + jobtype: 'printable_pdf_v2', + last_run: undefined, + next_run: expect.any(String), + payload: jsonPayload, + schedule: { + rrule: { + freq: 3, + interval: 3, + byhour: [12], + byminute: [0], + tzid: 'UTC', + }, + }, + space_id: 'a-space', + title: '[Logs] Web Traffic', + }, + { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + created_at: '2025-05-06T21:12:06.584Z', + created_by: 'not-elastic', + enabled: true, + jobtype: 'PNGV2', + last_run: '2025-05-06T21:12:07.198Z', + next_run: expect.any(String), + notification: { + email: { + to: ['user@elastic.co'], + }, + }, + payload: jsonPayload, + title: 'Another cool dashboard', + schedule: { + rrule: { + freq: 1, + interval: 3, + tzid: 'UTC', + }, + }, + space_id: 'a-space', + }, + ], + }); + }); + + it('handles malformed so with no namespace', () => { + const malformedSo1 = { ...savedObjects[0], namespaces: [], score: 0 }; + const malformedSo2 = { ...omit(savedObjects[1], 'namespaces'), score: 0 }; + expect( + transformResponse( + mockLogger, + { + page: 1, + per_page: 10, + total: 2, + saved_objects: [malformedSo1, malformedSo2], + }, + lastRunResponse + ) + ).toEqual({ + page: 1, + per_page: 10, + total: 2, + data: [ + { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + created_at: '2025-05-06T21:10:17.137Z', + created_by: 'elastic', + enabled: true, + jobtype: 'printable_pdf_v2', + last_run: '2025-05-06T12:00:00.500Z', + next_run: expect.any(String), + payload: jsonPayload, + schedule: { + rrule: { + freq: 3, + interval: 3, + byhour: [12], + byminute: [0], + tzid: 'UTC', + }, + }, + space_id: 'default', + title: '[Logs] Web Traffic', + }, + { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + created_at: '2025-05-06T21:12:06.584Z', + created_by: 'not-elastic', + enabled: true, + jobtype: 'PNGV2', + last_run: '2025-05-06T21:12:07.198Z', + next_run: expect.any(String), + notification: { + email: { + to: ['user@elastic.co'], + }, + }, + payload: jsonPayload, + title: 'Another cool dashboard', + schedule: { + rrule: { + freq: 1, + interval: 3, + tzid: 'UTC', + }, + }, + space_id: 'default', + }, + ], + }); + }); + + it('handles undefined last run response', () => { + expect(transformResponse(mockLogger, soResponse)).toEqual({ + page: 1, + per_page: 10, + total: 2, + data: [ + { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + created_at: '2025-05-06T21:10:17.137Z', + created_by: 'elastic', + enabled: true, + jobtype: 'printable_pdf_v2', + last_run: undefined, + next_run: expect.any(String), + payload: jsonPayload, + schedule: { + rrule: { + freq: 3, + interval: 3, + byhour: [12], + byminute: [0], + tzid: 'UTC', + }, + }, + space_id: 'a-space', + title: '[Logs] Web Traffic', + }, + { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + created_at: '2025-05-06T21:12:06.584Z', + created_by: 'not-elastic', + enabled: true, + jobtype: 'PNGV2', + last_run: undefined, + next_run: expect.any(String), + notification: { + email: { + to: ['user@elastic.co'], + }, + }, + payload: jsonPayload, + title: 'Another cool dashboard', + schedule: { + rrule: { + freq: 1, + interval: 3, + tzid: 'UTC', + }, + }, + space_id: 'a-space', + }, + ], + }); + }); +}); diff --git a/x-pack/platform/plugins/private/reporting/server/routes/common/scheduled/scheduled_query.ts b/x-pack/platform/plugins/private/reporting/server/routes/common/scheduled/scheduled_query.ts new file mode 100644 index 000000000000..f60aab8776ff --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/routes/common/scheduled/scheduled_query.ts @@ -0,0 +1,362 @@ +/* + * 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, + KibanaResponseFactory, + SavedObject, + SavedObjectsFindResponse, + SavedObjectsFindResult, +} from '@kbn/core/server'; +import type { Logger } from '@kbn/core/server'; +import { REPORTING_DATA_STREAM_WILDCARD_WITH_LEGACY } from '@kbn/reporting-server'; +import { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; +import { RRule } from '@kbn/rrule'; +import { DEFAULT_SPACE_ID } from '@kbn/spaces-utils'; +import { ReportApiJSON } from '@kbn/reporting-common/types'; +import type { ReportingCore } from '../../..'; +import type { + ListScheduledReportApiJSON, + ReportingUser, + ScheduledReportType, +} from '../../../types'; +import { SCHEDULED_REPORT_SAVED_OBJECT_TYPE } from '../../../saved_objects'; +import { ScheduledReportAuditAction, scheduledReportAuditEvent } from '../audit_events'; + +export const MAX_SCHEDULED_REPORT_LIST_SIZE = 100; +export const DEFAULT_SCHEDULED_REPORT_LIST_SIZE = 10; + +const SCHEDULED_REPORT_ID_FIELD = 'scheduled_report_id'; +const CREATED_AT_FIELD = 'created_at'; +const getUsername = (user: ReportingUser) => (user ? user.username : false); + +interface ApiResponse { + page: number; + per_page: number; + total: number; + data: ListScheduledReportApiJSON[]; +} + +const getEmptyApiResponse = (page: number, perPage: number) => ({ + page, + per_page: perPage, + total: 0, + data: [], +}); + +interface BulkOperationError { + message: string; + status?: number; + id: string; +} + +interface BulkDisableResult { + scheduled_report_ids: string[]; + errors: BulkOperationError[]; + total: number; +} + +export type CreatedAtSearchResponse = SearchResponse<{ created_at: string }>; + +export function transformSingleResponse( + logger: Logger, + so: SavedObjectsFindResult, + lastResponse?: CreatedAtSearchResponse +) { + const id = so.id; + const lastRunForId = (lastResponse?.hits.hits ?? []).find( + (hit) => hit.fields?.[SCHEDULED_REPORT_ID_FIELD]?.[0] === id + ); + + const schedule = so.attributes.schedule; + const _rrule = new RRule({ + ...schedule.rrule, + dtstart: new Date(), + }); + + let payload: ReportApiJSON['payload'] | undefined; + try { + payload = JSON.parse(so.attributes.payload); + } catch (e) { + logger.warn(`Failed to parse payload for scheduled report ${id}: ${e.message}`); + } + + return { + id, + created_at: so.attributes.createdAt, + created_by: so.attributes.createdBy, + enabled: so.attributes.enabled, + jobtype: so.attributes.jobType, + last_run: lastRunForId?._source?.[CREATED_AT_FIELD], + next_run: _rrule.after(new Date())?.toISOString(), + notification: so.attributes.notification, + payload, + schedule: so.attributes.schedule, + space_id: so.namespaces?.[0] ?? DEFAULT_SPACE_ID, + title: so.attributes.title, + }; +} + +export function transformResponse( + logger: Logger, + result: SavedObjectsFindResponse, + lastResponse?: CreatedAtSearchResponse +): ApiResponse { + return { + page: result.page, + per_page: result.per_page, + total: result.total, + data: result.saved_objects.map((so) => transformSingleResponse(logger, so, lastResponse)), + }; +} + +export interface ScheduledQueryFactory { + list( + logger: Logger, + req: KibanaRequest, + res: KibanaResponseFactory, + user: ReportingUser, + page: number, + size: number + ): Promise; + bulkDisable( + logger: Logger, + req: KibanaRequest, + res: KibanaResponseFactory, + ids: string[], + user: ReportingUser + ): Promise; +} + +export function scheduledQueryFactory(reportingCore: ReportingCore): ScheduledQueryFactory { + return { + async list(logger, req, res, user, page = 1, size = DEFAULT_SCHEDULED_REPORT_LIST_SIZE) { + try { + const esClient = await reportingCore.getEsClient(); + const auditLogger = await reportingCore.getAuditLogger(req); + const savedObjectsClient = await reportingCore.getScopedSoClient(req); + const username = getUsername(user); + + // if user has Manage Reporting privileges, we can list + // scheduled reports for all users in this space, otherwise + // we will filter only to the scheduled reports created by the user + const canManageReporting = await reportingCore.canManageReportingForSpace(req); + + const response = await savedObjectsClient.find({ + type: SCHEDULED_REPORT_SAVED_OBJECT_TYPE, + page, + perPage: size, + ...(!canManageReporting + ? { filter: `scheduled_report.attributes.createdBy: "${username}"` } + : {}), + }); + + if (!response) { + return getEmptyApiResponse(page, size); + } + + const scheduledReportIdsAndName = response?.saved_objects.map((so) => ({ + id: so.id, + name: so.attributes.title, + })); + + if (!scheduledReportIdsAndName || scheduledReportIdsAndName.length === 0) { + return getEmptyApiResponse(page, size); + } + + scheduledReportIdsAndName.forEach(({ id, name }) => + auditLogger.log( + scheduledReportAuditEvent({ + action: ScheduledReportAuditAction.LIST, + savedObject: { + type: SCHEDULED_REPORT_SAVED_OBJECT_TYPE, + id, + name, + }, + }) + ) + ); + + let lastRunResponse; + try { + lastRunResponse = (await esClient.asInternalUser.search({ + index: REPORTING_DATA_STREAM_WILDCARD_WITH_LEGACY, + size, + _source: [CREATED_AT_FIELD], + sort: [{ [CREATED_AT_FIELD]: { order: 'desc' } }], + query: { + bool: { + filter: [ + { + terms: { + [SCHEDULED_REPORT_ID_FIELD]: scheduledReportIdsAndName.map(({ id }) => id), + }, + }, + ], + }, + }, + collapse: { field: SCHEDULED_REPORT_ID_FIELD }, + })) as CreatedAtSearchResponse; + } catch (error) { + // if no scheduled reports have run yet, we will get an error from the collapse query + // ignore these and return an empty last run + logger.warn(`Error getting last run for scheduled reports: ${error.message}`); + } + + return transformResponse(logger, response, lastRunResponse); + } catch (error) { + throw res.customError({ + statusCode: 500, + body: `Error listing scheduled reports: ${error.message}`, + }); + } + }, + + async bulkDisable(logger, req, res, ids, user) { + try { + const savedObjectsClient = await reportingCore.getScopedSoClient(req); + const taskManager = await reportingCore.getTaskManager(); + const auditLogger = await reportingCore.getAuditLogger(req); + + const bulkErrors: BulkOperationError[] = []; + const disabledScheduledReportIds: Set = new Set(); + let taskIdsToDisable: string[] = []; + + const username = getUsername(user); + + // if user has Manage Reporting privileges, they can disable + // scheduled reports for all users in this space + const canManageReporting = await reportingCore.canManageReportingForSpace(req); + + const bulkGetResult = await savedObjectsClient.bulkGet( + ids.map((id) => ({ id, type: SCHEDULED_REPORT_SAVED_OBJECT_TYPE })) + ); + + const scheduledReportSavedObjectsToUpdate: Array> = []; + for (const so of bulkGetResult.saved_objects) { + if (so.error) { + bulkErrors.push({ + message: so.error.message, + status: so.error.statusCode, + id: so.id, + }); + } else { + // check if user is allowed to update this scheduled report + if (so.attributes.createdBy !== username && !canManageReporting) { + bulkErrors.push({ + message: `Not found.`, + status: 404, + id: so.id, + }); + logger.warn( + `User "${username}" attempted to disable scheduled report "${so.id}" created by "${so.attributes.createdBy}" without sufficient privileges.` + ); + auditLogger.log( + scheduledReportAuditEvent({ + action: ScheduledReportAuditAction.DISABLE, + savedObject: { + type: SCHEDULED_REPORT_SAVED_OBJECT_TYPE, + id: so.id, + name: so?.attributes?.title, + }, + error: new Error(`Not found.`), + }) + ); + } else if (so.attributes.enabled === false) { + logger.debug(`Scheduled report ${so.id} is already disabled`); + disabledScheduledReportIds.add(so.id); + } else { + auditLogger.log( + scheduledReportAuditEvent({ + action: ScheduledReportAuditAction.DISABLE, + savedObject: { + type: SCHEDULED_REPORT_SAVED_OBJECT_TYPE, + id: so.id, + name: so.attributes.title, + }, + outcome: 'unknown', + }) + ); + scheduledReportSavedObjectsToUpdate.push(so); + } + } + } + + // nothing to update, return early + if (scheduledReportSavedObjectsToUpdate.length > 0) { + const bulkUpdateResult = await savedObjectsClient.bulkUpdate( + scheduledReportSavedObjectsToUpdate.map((so) => ({ + id: so.id, + type: so.type, + attributes: { + enabled: false, + }, + })) + ); + + for (const so of bulkUpdateResult.saved_objects) { + if (so.error) { + bulkErrors.push({ + message: so.error.message, + status: so.error.statusCode, + id: so.id, + }); + auditLogger.log( + scheduledReportAuditEvent({ + action: ScheduledReportAuditAction.DISABLE, + savedObject: { + type: SCHEDULED_REPORT_SAVED_OBJECT_TYPE, + id: so.id, + name: so?.attributes?.title, + }, + error: new Error(so.error.message), + }) + ); + } else { + taskIdsToDisable.push(so.id); + } + } + } else { + return { + scheduled_report_ids: [...disabledScheduledReportIds], + errors: bulkErrors, + total: disabledScheduledReportIds.size + bulkErrors.length, + }; + } + + // it's possible that the scheduled_report saved object was disabled but + // task disabling failed so add the list of already disabled IDs + // task manager filters out disabled tasks so this will not cause extra load + taskIdsToDisable = taskIdsToDisable.concat([...disabledScheduledReportIds]); + + const resultFromDisablingTasks = await taskManager.bulkDisable(taskIdsToDisable); + for (const error of resultFromDisablingTasks.errors) { + bulkErrors.push({ + message: `Scheduled report disabled but task disabling failed due to: ${error.error.message}`, + status: error.error.statusCode, + id: error.id, + }); + } + + for (const result of resultFromDisablingTasks.tasks) { + disabledScheduledReportIds.add(result.id); + } + + return { + scheduled_report_ids: [...disabledScheduledReportIds], + errors: bulkErrors, + total: disabledScheduledReportIds.size + bulkErrors.length, + }; + } catch (error) { + throw res.customError({ + statusCode: 500, + body: `Error disabling scheduled reports: ${error.message}`, + }); + } + }, + }; +} diff --git a/x-pack/platform/plugins/private/reporting/server/routes/index.ts b/x-pack/platform/plugins/private/reporting/server/routes/index.ts index f9fbd12802e2..f8436002f08f 100644 --- a/x-pack/platform/plugins/private/reporting/server/routes/index.ts +++ b/x-pack/platform/plugins/private/reporting/server/routes/index.ts @@ -11,7 +11,9 @@ import { registerDeprecationsRoutes } from './internal/deprecations/deprecations import { registerDiagnosticRoutes } from './internal/diagnostic'; import { registerHealthRoute } from './internal/health'; import { registerGenerationRoutesInternal } from './internal/generate/generate_from_jobparams'; +import { registerScheduleRoutesInternal } from './internal/schedule/schedule_from_jobparams'; import { registerJobInfoRoutesInternal } from './internal/management/jobs'; +import { registerScheduledRoutesInternal } from './internal/management/scheduled'; import { registerGenerationRoutesPublic } from './public/generate_from_jobparams'; import { registerJobInfoRoutesPublic } from './public/jobs'; @@ -20,7 +22,9 @@ export function registerRoutes(reporting: ReportingCore, logger: Logger) { registerHealthRoute(reporting, logger); registerDiagnosticRoutes(reporting, logger); registerGenerationRoutesInternal(reporting, logger); + registerScheduleRoutesInternal(reporting, logger); registerJobInfoRoutesInternal(reporting); + registerScheduledRoutesInternal(reporting, logger); registerGenerationRoutesPublic(reporting, logger); registerJobInfoRoutesPublic(reporting); } diff --git a/x-pack/platform/plugins/private/reporting/server/routes/internal/generate/generate_from_jobparams.ts b/x-pack/platform/plugins/private/reporting/server/routes/internal/generate/generate_from_jobparams.ts index bd26c88bf6a0..2d762ebf1cdb 100644 --- a/x-pack/platform/plugins/private/reporting/server/routes/internal/generate/generate_from_jobparams.ts +++ b/x-pack/platform/plugins/private/reporting/server/routes/internal/generate/generate_from_jobparams.ts @@ -10,7 +10,7 @@ import type { Logger } from '@kbn/core/server'; import { INTERNAL_ROUTES } from '@kbn/reporting-common'; import type { ReportingCore } from '../../..'; import { authorizedUserPreRouting } from '../../common'; -import { RequestHandler } from '../../common/generate'; +import { GenerateRequestHandler } from '../../common/request_handler'; const { GENERATE_PREFIX } = INTERNAL_ROUTES; @@ -30,7 +30,7 @@ export function registerGenerationRoutesInternal(reporting: ReportingCore, logge requiredPrivileges: kibanaAccessControlTags, }, }, - validate: RequestHandler.getValidation(), + validate: GenerateRequestHandler.getValidation(), options: { tags: kibanaAccessControlTags.map((accessControlTag) => `access:${accessControlTag}`), access: 'internal', @@ -38,17 +38,20 @@ export function registerGenerationRoutesInternal(reporting: ReportingCore, logge }, authorizedUserPreRouting(reporting, async (user, context, req, res) => { try { - const requestHandler = new RequestHandler( + const requestHandler = new GenerateRequestHandler({ reporting, user, context, path, req, res, - logger - ); + logger, + }); const jobParams = requestHandler.getJobParams(); - return await requestHandler.handleGenerateRequest(req.params.exportType, jobParams); + return await requestHandler.handleRequest({ + exportTypeId: req.params.exportType, + jobParams, + }); } catch (err) { if (err instanceof KibanaResponse) { return err; diff --git a/x-pack/platform/plugins/private/reporting/server/routes/internal/management/jobs.ts b/x-pack/platform/plugins/private/reporting/server/routes/internal/management/jobs.ts index 1f631f0f0158..a19e690a21e6 100644 --- a/x-pack/platform/plugins/private/reporting/server/routes/internal/management/jobs.ts +++ b/x-pack/platform/plugins/private/reporting/server/routes/internal/management/jobs.ts @@ -10,7 +10,7 @@ import { INTERNAL_ROUTES } from '@kbn/reporting-common'; import { ROUTE_TAG_CAN_REDIRECT } from '@kbn/security-plugin/server'; import { ReportingCore } from '../../..'; import { authorizedUserPreRouting, getCounters } from '../../common'; -import { handleUnavailable } from '../../common/generate'; +import { handleUnavailable } from '../../common/request_handler'; import { commonJobsRouteHandlerFactory, jobManagementPreRouting, diff --git a/x-pack/platform/plugins/private/reporting/server/routes/internal/management/scheduled.ts b/x-pack/platform/plugins/private/reporting/server/routes/internal/management/scheduled.ts new file mode 100644 index 000000000000..95ba5b8b0a79 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/routes/internal/management/scheduled.ts @@ -0,0 +1,142 @@ +/* + * 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 type { Logger } from '@kbn/core/server'; +import { INTERNAL_ROUTES } from '@kbn/reporting-common'; +import { KibanaResponse } from '@kbn/core-http-router-server-internal'; +import { ReportingCore } from '../../..'; +import { authorizedUserPreRouting, getCounters } from '../../common'; +import { handleUnavailable } from '../../common/request_handler'; +import { scheduledQueryFactory } from '../../common/scheduled'; +import { + DEFAULT_SCHEDULED_REPORT_LIST_SIZE, + MAX_SCHEDULED_REPORT_LIST_SIZE, +} from '../../common/scheduled/scheduled_query'; + +const { SCHEDULED } = INTERNAL_ROUTES; + +export function registerScheduledRoutesInternal(reporting: ReportingCore, logger: Logger) { + const setupDeps = reporting.getPluginSetupDeps(); + const { router } = setupDeps; + const scheduledQuery = scheduledQueryFactory(reporting); + + const registerInternalGetList = () => { + // list scheduled jobs in the queue, paginated + const path = SCHEDULED.LIST; + router.get( + { + path, + security: { + authz: { + enabled: false, + reason: + 'This route is opted out from authorization because reporting uses its own authorization model.', + }, + }, + validate: { + query: schema.object({ + page: schema.string({ defaultValue: '1' }), + size: schema.string({ + defaultValue: `${DEFAULT_SCHEDULED_REPORT_LIST_SIZE}`, + validate: (value: string) => { + try { + const size = parseInt(value, 10); + if (size < 1 || size > MAX_SCHEDULED_REPORT_LIST_SIZE) { + return `size must be between 1 and ${MAX_SCHEDULED_REPORT_LIST_SIZE}: size: ${value}`; + } + } catch (e) { + return `size must be an integer: size: ${value}`; + } + }, + }), + }), + }, + options: { access: 'internal' }, + }, + authorizedUserPreRouting(reporting, async (user, context, req, res) => { + try { + const counters = getCounters(req.route.method, path, reporting.getUsageCounter()); + + // ensure the async dependencies are loaded + if (!context.reporting) { + return handleUnavailable(res); + } + + const { + page: queryPage = '1', + size: querySize = `${DEFAULT_SCHEDULED_REPORT_LIST_SIZE}`, + } = req.query; + const page = parseInt(queryPage, 10) || 1; + const size = Math.min( + MAX_SCHEDULED_REPORT_LIST_SIZE, + parseInt(querySize, 10) || DEFAULT_SCHEDULED_REPORT_LIST_SIZE + ); + const results = await scheduledQuery.list(logger, req, res, user, page, size); + + counters.usageCounter(); + + return res.ok({ body: results, headers: { 'content-type': 'application/json' } }); + } catch (err) { + if (err instanceof KibanaResponse) { + return err; + } + throw err; + } + }) + ); + }; + + const registerInternalPatchBulkDisable = () => { + // allow scheduled reports to be disabled + const path = SCHEDULED.BULK_DISABLE; + + router.patch( + { + path, + security: { + authz: { + enabled: false, + reason: 'This route is opted out from authorization', + }, + }, + validate: { + body: schema.object({ + ids: schema.arrayOf(schema.string(), { minSize: 1, maxSize: 1000 }), + }), + }, + options: { access: 'internal' }, + }, + authorizedUserPreRouting(reporting, async (user, context, req, res) => { + try { + const counters = getCounters(req.route.method, path, reporting.getUsageCounter()); + + // ensure the async dependencies are loaded + if (!context.reporting) { + return handleUnavailable(res); + } + + const { ids } = req.body; + + const results = await scheduledQuery.bulkDisable(logger, req, res, ids, user); + + counters.usageCounter(); + + return res.ok({ body: results, headers: { 'content-type': 'application/json' } }); + } catch (err) { + if (err instanceof KibanaResponse) { + return err; + } + throw err; + } + }) + ); + }; + + registerInternalGetList(); + registerInternalPatchBulkDisable(); +} diff --git a/x-pack/platform/plugins/private/reporting/server/routes/internal/schedule/integration_tests/scheduling_from_jobparams.test.ts b/x-pack/platform/plugins/private/reporting/server/routes/internal/schedule/integration_tests/scheduling_from_jobparams.test.ts new file mode 100644 index 000000000000..e30333e0586f --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/routes/internal/schedule/integration_tests/scheduling_from_jobparams.test.ts @@ -0,0 +1,360 @@ +/* + * 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 rison from '@kbn/rison'; +import { BehaviorSubject } from 'rxjs'; +import supertest from 'supertest'; + +import { setupServer } from '@kbn/core-test-helpers-test-utils'; +import { coreMock, loggingSystemMock } from '@kbn/core/server/mocks'; +import { licensingMock } from '@kbn/licensing-plugin/server/mocks'; +import { INTERNAL_ROUTES } from '@kbn/reporting-common'; +import { PdfExportType } from '@kbn/reporting-export-types-pdf'; +import { createMockConfigSchema } from '@kbn/reporting-mocks-server'; +import { ExportTypesRegistry } from '@kbn/reporting-server/export_types_registry'; + +import { ReportingCore } from '../../../..'; +import { reportingMock } from '../../../../mocks'; +import { + createMockPluginSetup, + createMockPluginStart, + createMockReportingCore, +} from '../../../../test_helpers'; +import { ReportingRequestHandlerContext } from '../../../../types'; +import { registerScheduleRoutesInternal } from '../schedule_from_jobparams'; +import { FakeRawRequest, KibanaRequest, SavedObjectsClientContract } from '@kbn/core/server'; +import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; + +type SetupServerReturn = Awaited>; + +const fakeRawRequest: FakeRawRequest = { + headers: { + authorization: `ApiKey skdjtq4u543yt3rhewrh`, + }, + path: '/', +}; + +describe(`POST ${INTERNAL_ROUTES.SCHEDULE_PREFIX}`, () => { + const reportingSymbol = Symbol('reporting'); + let server: SetupServerReturn['server']; + let httpSetup: SetupServerReturn['httpSetup']; + let mockExportTypesRegistry: ExportTypesRegistry; + let reportingCore: ReportingCore; + let soClient: SavedObjectsClientContract; + + const mockConfigSchema = createMockConfigSchema({ + queue: { indexInterval: 'year', timeout: 10000, pollEnabled: true }, + }); + + const mockLogger = loggingSystemMock.createLogger(); + const mockCoreSetup = coreMock.createSetup(); + const auditLogger = auditLoggerMock.create(); + const mockPdfExportType = new PdfExportType( + mockCoreSetup, + mockConfigSchema, + mockLogger, + coreMock.createPluginInitializerContext(mockConfigSchema) + ); + + beforeEach(async () => { + ({ server, httpSetup } = await setupServer(reportingSymbol)); + httpSetup.registerRouteHandlerContext( + reportingSymbol, + 'reporting', + () => reportingMock.createStart() + ); + + const mockSetupDeps = createMockPluginSetup({ + security: { license: { isEnabled: () => true, getFeature: () => true } }, + router: httpSetup.createRouter(''), + }); + + const mockStartDeps = await createMockPluginStart( + { + licensing: { + ...licensingMock.createStart(), + license$: new BehaviorSubject({ + isActive: true, + isAvailable: true, + type: 'gold', + getFeature: () => true, + }), + }, + securityService: { + authc: { + apiKeys: { areAPIKeysEnabled: () => true }, + getCurrentUser: () => ({ id: '123', roles: ['superuser'], username: 'Tom Riddle' }), + }, + audit: { + asScoped: () => auditLogger, + }, + }, + }, + mockConfigSchema + ); + + reportingCore = await createMockReportingCore(mockConfigSchema, mockSetupDeps, mockStartDeps); + jest.spyOn(reportingCore, 'getHealthInfo').mockResolvedValue({ + isSufficientlySecure: true, + hasPermanentEncryptionKey: true, + areNotificationsEnabled: true, + }); + + mockExportTypesRegistry = new ExportTypesRegistry(); + mockExportTypesRegistry.register(mockPdfExportType); + + soClient = await reportingCore.getScopedSoClient(fakeRawRequest as unknown as KibanaRequest); + soClient.create = jest.fn().mockImplementation(async (_, opts) => { + return { + id: 'foo', + attributes: opts, + type: 'scheduled-report', + }; + }); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('returns 400 if there are no job params', async () => { + registerScheduleRoutesInternal(reportingCore, mockLogger); + + await server.start(); + + await supertest(httpSetup.server.listener) + .post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`) + .expect(400) + .then(({ body }) => + expect(body.message).toMatchInlineSnapshot( + '"[request body]: expected a plain object value, but found [null] instead."' + ) + ); + }); + + it('returns 400 if job params body is invalid', async () => { + registerScheduleRoutesInternal(reportingCore, mockLogger); + + await server.start(); + + await supertest(httpSetup.server.listener) + .post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`) + .send({ jobParams: `foo:`, schedule: { rrule: { freq: 1, interval: 2 } } }) + .expect(400) + .then(({ body }) => expect(body.message).toMatchInlineSnapshot('"invalid rison: foo:"')); + }); + + it('returns 400 export type is invalid', async () => { + registerScheduleRoutesInternal(reportingCore, mockLogger); + + await server.start(); + + await supertest(httpSetup.server.listener) + .post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/TonyHawksProSkater2`) + .send({ + schedule: { rrule: { freq: 1, interval: 2 } }, + jobParams: rison.encode({ title: `abc` }), + }) + .expect(400) + .then(({ body }) => + expect(body.message).toMatchInlineSnapshot('"Invalid export-type of TonyHawksProSkater2"') + ); + }); + + it('returns 400 on invalid browser timezone', async () => { + registerScheduleRoutesInternal(reportingCore, mockLogger); + + await server.start(); + + await supertest(httpSetup.server.listener) + .post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`) + .send({ + jobParams: rison.encode({ browserTimezone: 'America/Amsterdam', title: `abc` }), + schedule: { rrule: { freq: 1, interval: 2 } }, + }) + .expect(400) + .then(({ body }) => + expect(body.message).toMatchInlineSnapshot(`"Invalid timezone \\"America/Amsterdam\\"."`) + ); + }); + + it('returns 400 on invalid rrule', async () => { + registerScheduleRoutesInternal(reportingCore, mockLogger); + + await server.start(); + + await supertest(httpSetup.server.listener) + .post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`) + .send({ + jobParams: rison.encode({ browserTimezone: 'America/Amsterdam', title: `abc` }), + schedule: { rrule: { freq: 6, interval: 2 } }, + }) + .expect(400) + .then(({ body }) => + expect(body.message).toMatchInlineSnapshot(` + "[request body.schedule.rrule]: types that failed validation: + - [request body.schedule.rrule.0.freq]: expected value to equal [1] + - [request body.schedule.rrule.1.freq]: expected value to equal [2] + - [request body.schedule.rrule.2.freq]: expected value to equal [3]" + `) + ); + }); + + it('returns 400 on invalid notification list', async () => { + registerScheduleRoutesInternal(reportingCore, mockLogger); + + await server.start(); + + await supertest(httpSetup.server.listener) + .post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`) + .send({ + jobParams: rison.encode({ browserTimezone: 'America/Amsterdam', title: `abc` }), + schedule: { rrule: { freq: 1, interval: 2 } }, + notification: { + email: { + to: 'single@email.com', + }, + }, + }) + .expect(400) + .then(({ body }) => + expect(body.message).toMatchInlineSnapshot( + `"[request body.notification.email.to]: could not parse array value from json input"` + ) + ); + }); + + it('returns 400 on empty notification list', async () => { + registerScheduleRoutesInternal(reportingCore, mockLogger); + + await server.start(); + + await supertest(httpSetup.server.listener) + .post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`) + .send({ + jobParams: rison.encode({ browserTimezone: 'America/Amsterdam', title: `abc` }), + schedule: { rrule: { freq: 1, interval: 2 } }, + notification: { + email: { + to: [], + }, + }, + }) + .expect(400) + .then(({ body }) => + expect(body.message).toMatchInlineSnapshot( + `"[request body.notification.email]: At least one email address is required"` + ) + ); + }); + + it('returns 403 on when no permanent encryption key', async () => { + jest.spyOn(reportingCore, 'getHealthInfo').mockResolvedValueOnce({ + isSufficientlySecure: true, + hasPermanentEncryptionKey: false, + areNotificationsEnabled: false, + }); + registerScheduleRoutesInternal(reportingCore, mockLogger); + + await server.start(); + + await supertest(httpSetup.server.listener) + .post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`) + .send({ + jobParams: rison.encode({ title: `abc` }), + schedule: { rrule: { freq: 1, interval: 2 } }, + }) + .expect(403) + .then(({ body }) => + expect(body.message).toMatchInlineSnapshot( + `"Permanent encryption key must be set for scheduled reporting"` + ) + ); + }); + + it('returns 403 on when not sufficiently secure', async () => { + jest.spyOn(reportingCore, 'getHealthInfo').mockResolvedValueOnce({ + isSufficientlySecure: false, + hasPermanentEncryptionKey: true, + areNotificationsEnabled: false, + }); + registerScheduleRoutesInternal(reportingCore, mockLogger); + + await server.start(); + + await supertest(httpSetup.server.listener) + .post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`) + .send({ + jobParams: rison.encode({ title: `abc` }), + schedule: { rrule: { freq: 1, interval: 2 } }, + }) + .expect(403) + .then(({ body }) => + expect(body.message).toMatchInlineSnapshot( + `"Security and API keys must be enabled for scheduled reporting"` + ) + ); + }); + + it('returns 500 if job handler throws an error', async () => { + soClient.create = jest.fn().mockRejectedValue('silly'); + + registerScheduleRoutesInternal(reportingCore, mockLogger); + + await server.start(); + + await supertest(httpSetup.server.listener) + .post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`) + .send({ + jobParams: rison.encode({ title: `abc` }), + schedule: { rrule: { freq: 1, interval: 2 } }, + }) + .expect(500); + }); + + it(`returns 200 if job handler doesn't error`, async () => { + registerScheduleRoutesInternal(reportingCore, mockLogger); + + await server.start(); + + await supertest(httpSetup.server.listener) + .post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`) + .send({ + jobParams: rison.encode({ + title: `abc`, + layout: { id: 'test' }, + objectType: 'canvas workpad', + }), + notification: { + email: { + bcc: ['single@email.com'], + }, + }, + schedule: { rrule: { freq: 1, interval: 2 } }, + }) + .expect(200) + .then(({ body }) => { + expect(body).toMatchObject({ + job: { + created_by: 'Tom Riddle', + id: 'foo', + jobtype: 'printable_pdf_v2', + payload: { + isDeprecated: false, + layout: { + id: 'test', + }, + objectType: 'canvas workpad', + title: 'abc', + version: '7.14.0', + }, + schedule: { rrule: { freq: 1, interval: 2 } }, + }, + }); + }); + }); +}); diff --git a/x-pack/platform/plugins/private/reporting/server/routes/internal/schedule/schedule_from_jobparams.ts b/x-pack/platform/plugins/private/reporting/server/routes/internal/schedule/schedule_from_jobparams.ts new file mode 100644 index 000000000000..0c501c432189 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/routes/internal/schedule/schedule_from_jobparams.ts @@ -0,0 +1,71 @@ +/* + * 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 { KibanaResponse } from '@kbn/core-http-router-server-internal'; +import type { Logger } from '@kbn/core/server'; +import { INTERNAL_ROUTES } from '@kbn/reporting-common'; +import type { ReportingCore } from '../../..'; +import { authorizedUserPreRouting } from '../../common'; +import { ScheduleRequestHandler } from '../../common/request_handler'; + +const { SCHEDULE_PREFIX } = INTERNAL_ROUTES; + +export function registerScheduleRoutesInternal(reporting: ReportingCore, logger: Logger) { + const setupDeps = reporting.getPluginSetupDeps(); + const { router } = setupDeps; + + const kibanaAccessControlTags = ['generateReport']; + + const registerInternalPostScheduleEndpoint = () => { + const path = `${SCHEDULE_PREFIX}/{exportType}`; + router.post( + { + path, + security: { + authz: { + requiredPrivileges: kibanaAccessControlTags, + }, + }, + validate: ScheduleRequestHandler.getValidation(), + options: { + tags: kibanaAccessControlTags.map((accessControlTag) => `access:${accessControlTag}`), + access: 'internal', + }, + }, + authorizedUserPreRouting(reporting, async (user, context, req, res) => { + try { + const requestHandler = new ScheduleRequestHandler({ + reporting, + user, + context, + path, + req, + res, + logger, + }); + const jobParams = requestHandler.getJobParams(); + const schedule = requestHandler.getSchedule(); + const notification = requestHandler.getNotification(); + + return await requestHandler.handleRequest({ + exportTypeId: req.params.exportType, + jobParams, + schedule, + notification, + }); + } catch (err) { + if (err instanceof KibanaResponse) { + return err; + } + throw err; + } + }) + ); + }; + + registerInternalPostScheduleEndpoint(); +} diff --git a/x-pack/platform/plugins/private/reporting/server/routes/public/generate_from_jobparams.ts b/x-pack/platform/plugins/private/reporting/server/routes/public/generate_from_jobparams.ts index f547faa9cab5..34507cf79d56 100644 --- a/x-pack/platform/plugins/private/reporting/server/routes/public/generate_from_jobparams.ts +++ b/x-pack/platform/plugins/private/reporting/server/routes/public/generate_from_jobparams.ts @@ -10,7 +10,7 @@ import type { Logger } from '@kbn/core/server'; import { PUBLIC_ROUTES } from '@kbn/reporting-common'; import type { ReportingCore } from '../..'; import { authorizedUserPreRouting } from '../common'; -import { RequestHandler } from '../common/generate'; +import { GenerateRequestHandler } from '../common/request_handler'; export function registerGenerationRoutesPublic(reporting: ReportingCore, logger: Logger) { const setupDeps = reporting.getPluginSetupDeps(); @@ -28,7 +28,7 @@ export function registerGenerationRoutesPublic(reporting: ReportingCore, logger: requiredPrivileges: kibanaAccessControlTags, }, }, - validate: RequestHandler.getValidation(), + validate: GenerateRequestHandler.getValidation(), options: { tags: kibanaAccessControlTags.map((controlAccessTag) => `access:${controlAccessTag}`), access: 'public', @@ -36,19 +36,19 @@ export function registerGenerationRoutesPublic(reporting: ReportingCore, logger: }, authorizedUserPreRouting(reporting, async (user, context, req, res) => { try { - const requestHandler = new RequestHandler( + const requestHandler = new GenerateRequestHandler({ reporting, user, context, path, req, res, - logger - ); - return await requestHandler.handleGenerateRequest( - req.params.exportType, - requestHandler.getJobParams() - ); + logger, + }); + return await requestHandler.handleRequest({ + exportTypeId: req.params.exportType, + jobParams: requestHandler.getJobParams(), + }); } catch (err) { if (err instanceof KibanaResponse) { return err; diff --git a/x-pack/platform/plugins/private/reporting/server/saved_objects/index.ts b/x-pack/platform/plugins/private/reporting/server/saved_objects/index.ts new file mode 100644 index 000000000000..df34d38fc50d --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/saved_objects/index.ts @@ -0,0 +1,24 @@ +/* + * 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 { SavedObjectsServiceSetup } from '@kbn/core/server'; +import { ALERTING_CASES_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; +import { scheduledReportMappings, scheduledReportModelVersions } from './scheduled_report'; + +export const SCHEDULED_REPORT_SAVED_OBJECT_TYPE = 'scheduled_report'; + +export function setupSavedObjects(savedObjects: SavedObjectsServiceSetup) { + savedObjects.registerType({ + name: SCHEDULED_REPORT_SAVED_OBJECT_TYPE, + indexPattern: ALERTING_CASES_SAVED_OBJECT_INDEX, + hidden: true, + namespaceType: 'multiple', + mappings: scheduledReportMappings, + management: { importableAndExportable: false }, + modelVersions: scheduledReportModelVersions, + }); +} diff --git a/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/index.ts b/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/index.ts new file mode 100644 index 000000000000..285297b977ee --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/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 { scheduledReportMappings } from './mappings'; +export { scheduledReportModelVersions } from './model_versions'; diff --git a/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/mappings.ts b/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/mappings.ts new file mode 100644 index 000000000000..26520db4d22a --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/mappings.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 { SavedObjectsTypeMappingDefinition } from '@kbn/core/server'; + +export const scheduledReportMappings: SavedObjectsTypeMappingDefinition = { + dynamic: false, + properties: { + createdBy: { + type: 'keyword', + }, + }, +}; diff --git a/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/model_versions.ts b/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/model_versions.ts new file mode 100644 index 000000000000..4123d20974d6 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/model_versions.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. + */ + +import type { SavedObjectsModelVersionMap } from '@kbn/core-saved-objects-server'; +import { rawScheduledReportSchemaV1 } from './schemas'; + +export const scheduledReportModelVersions: SavedObjectsModelVersionMap = { + '1': { + changes: [], + schemas: { + forwardCompatibility: rawScheduledReportSchemaV1.extends({}, { unknowns: 'ignore' }), + create: rawScheduledReportSchemaV1, + }, + }, +}; diff --git a/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/schemas/index.ts b/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/schemas/index.ts new file mode 100644 index 000000000000..6df4417bb6ce --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/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 { rawScheduledReportSchema as rawScheduledReportSchemaV1 } from './v1'; diff --git a/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/schemas/latest.ts b/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/schemas/latest.ts new file mode 100644 index 000000000000..6f684f9d7cbd --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/schemas/latest.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 type { TypeOf } from '@kbn/config-schema'; +import type { rawNotificationSchema, rawScheduledReportSchema } from './v1'; + +export type RawNotification = TypeOf; +export type RawScheduledReport = TypeOf; diff --git a/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/schemas/v1.ts b/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/schemas/v1.ts new file mode 100644 index 000000000000..4a5dcced733b --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/schemas/v1.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { scheduleRruleSchema } from '@kbn/task-manager-plugin/server'; + +const rawLayoutIdSchema = schema.oneOf([ + schema.literal('preserve_layout'), + schema.literal('print'), + schema.literal('canvas'), +]); + +export const rawNotificationSchema = schema.object({ + email: schema.maybe( + schema.object( + { + to: schema.maybe(schema.arrayOf(schema.string())), + bcc: schema.maybe(schema.arrayOf(schema.string())), + cc: schema.maybe(schema.arrayOf(schema.string())), + }, + { + validate: (value) => { + const allEmails = new Set([ + ...(value.to || []), + ...(value.bcc || []), + ...(value.cc || []), + ]); + + if (allEmails.size === 0) { + return 'At least one email address is required'; + } + }, + } + ) + ), +}); + +export const rawScheduledReportSchema = schema.object({ + createdAt: schema.string(), + createdBy: schema.string(), + enabled: schema.boolean(), + jobType: schema.string(), + meta: schema.object({ + isDeprecated: schema.maybe(schema.boolean()), + layout: schema.maybe(rawLayoutIdSchema), + objectType: schema.string(), + }), + migrationVersion: schema.maybe(schema.string()), + notification: schema.maybe(rawNotificationSchema), + payload: schema.string(), + schedule: scheduleRruleSchema, + title: schema.string(), +}); diff --git a/x-pack/platform/plugins/private/reporting/server/services/notifications/email_notification_service.test.ts b/x-pack/platform/plugins/private/reporting/server/services/notifications/email_notification_service.test.ts new file mode 100644 index 000000000000..1a5e15413222 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/services/notifications/email_notification_service.test.ts @@ -0,0 +1,115 @@ +/* + * 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 { elasticsearchServiceMock } from '@kbn/core/server/mocks'; +import { notificationsMock } from '@kbn/notifications-plugin/server/mocks'; +import { createMockConfigSchema } from '@kbn/reporting-mocks-server'; +import { set } from '@kbn/safer-lodash-set'; +import { ReportingCore } from '../..'; +import { createMockReportingCore } from '../../test_helpers'; +import { EmailNotificationService } from './email_notification_service'; + +describe('EmailNotificationService', () => { + const notifications = notificationsMock.createStart(); + let mockEsClient: ReturnType; + let emailNotificationService: EmailNotificationService; + let mockCore: ReportingCore; + + beforeEach(async () => { + jest.clearAllMocks(); + notifications.isEmailServiceAvailable.mockReturnValue(true); + emailNotificationService = new EmailNotificationService({ + notifications, + }); + const reportingConfig = { + index: '.reporting-test', + queue: { indexInterval: 'week' }, + statefulSettings: { enabled: true }, + }; + mockCore = await createMockReportingCore(createMockConfigSchema(reportingConfig)); + mockEsClient = (await mockCore.getEsClient()).asInternalUser as typeof mockEsClient; + }); + + it('notify()', async () => { + const base64Content = Buffer.from('test-output').toString('base64'); + mockEsClient.search.mockResponse( + set({}, 'hits.hits.0._source', { + jobtype: 'pdf', + output: { + content: base64Content, + size: 12, + }, + }) + ); + await emailNotificationService.notify({ + reporting: mockCore, + index: '.reporting-test-1234', + id: '1234', + filename: 'test-report.pdf', + contentType: 'test-content-type', + relatedObject: { + id: 'report-so-id', + type: 'scheduled-report', + namespace: 'space1', + }, + emailParams: { + to: ['test@test.com'], + subject: 'Scheduled report for 04/18/2025', + spaceId: 'space1', + }, + }); + expect(notifications.getEmailService().sendAttachmentEmail).toHaveBeenCalledWith({ + attachments: [ + { + content: 'dGVzdC1vdXRwdXR0ZXN0LW91dHB1dA==', + contentType: 'test-content-type', + encoding: 'base64', + filename: 'test-report.pdf', + }, + ], + context: { + relatedObjects: [ + { + id: 'report-so-id', + namespace: 'space1', + type: 'scheduled-report', + }, + ], + }, + message: 'Your scheduled report is attached for you to download or share.', + spaceId: 'space1', + subject: 'Scheduled report for 04/18/2025', + to: ['test@test.com'], + }); + }); + + it('throws an error when the email service is not available', async () => { + notifications.isEmailServiceAvailable.mockReturnValue(false); + + await expect( + emailNotificationService.notify({ + reporting: mockCore, + index: '.reporting-test-1234', + id: '1234', + filename: 'test-report.pdf', + contentType: 'test-content-type', + relatedObject: { + id: 'report-so-id', + type: 'scheduled-report', + namespace: 'space1', + }, + emailParams: { + to: ['test@test.com'], + subject: 'Scheduled report for 04/18/2025', + spaceId: 'space1', + }, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Email notification service is not available"`); + + expect(notifications.getEmailService().sendAttachmentEmail).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/platform/plugins/private/reporting/server/services/notifications/email_notification_service.ts b/x-pack/platform/plugins/private/reporting/server/services/notifications/email_notification_service.ts new file mode 100644 index 000000000000..3881ea05eb42 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/services/notifications/email_notification_service.ts @@ -0,0 +1,81 @@ +/* + * 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 { NotificationsPluginStart } from '@kbn/notifications-plugin/server'; +import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common'; +import type { NotificationService, NotifyArgs } from './types'; +import { ReportingCore } from '../..'; +import { getContentStream } from '../../lib'; + +export interface Attachment { + content: string; + contentType?: string; + encoding?: string; + filename: string; +} + +export class EmailNotificationService implements NotificationService { + private readonly notifications: NotificationsPluginStart; + + constructor({ notifications }: { notifications: NotificationsPluginStart }) { + this.notifications = notifications; + } + + private async getAttachments( + reporting: ReportingCore, + index: string, + id: string, + filename: string, + contentType?: string | null + ): Promise { + const stream = await getContentStream(reporting, { id, index }); + const buffers: Buffer[] = []; + for await (const chunk of stream) { + buffers.push(chunk); + } + const content = Buffer.concat(buffers); + + return [ + { + content: content.toString('base64'), + ...(contentType ? { contentType } : {}), + filename, + encoding: 'base64', + }, + ]; + } + + public async notify({ + reporting, + index, + id, + contentType, + filename, + relatedObject, + emailParams, + }: NotifyArgs) { + if (!this.notifications.isEmailServiceAvailable()) { + throw new Error('Email notification service is not available'); + } + + const attachments = await this.getAttachments(reporting, index, id, filename, contentType); + const { to, bcc, cc, subject, spaceId } = emailParams; + const message = 'Your scheduled report is attached for you to download or share.'; + await this.notifications.getEmailService().sendAttachmentEmail({ + to, + bcc, + cc, + subject, + message, + attachments, + spaceId: spaceId ?? DEFAULT_SPACE_ID, + context: { + relatedObjects: [relatedObject], + }, + }); + } +} diff --git a/x-pack/platform/plugins/private/reporting/server/services/notifications/types.ts b/x-pack/platform/plugins/private/reporting/server/services/notifications/types.ts new file mode 100644 index 000000000000..469eb7249e61 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/services/notifications/types.ts @@ -0,0 +1,28 @@ +/* + * 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 { RelatedSavedObject } from '@kbn/notifications-plugin/server/services/types'; +import { ReportingCore } from '../..'; + +export interface NotifyArgs { + reporting: ReportingCore; + index: string; + id: string; + contentType?: string | null; + filename: string; + relatedObject: RelatedSavedObject; + emailParams: { + to?: string[]; + bcc?: string[]; + cc?: string[]; + subject: string; + spaceId?: string; + }; +} + +export interface NotificationService { + notify: (args: NotifyArgs) => Promise; +} diff --git a/x-pack/platform/plugins/private/reporting/server/test_helpers/create_mock_reportingplugin.ts b/x-pack/platform/plugins/private/reporting/server/test_helpers/create_mock_reportingplugin.ts index 36b4420f1670..2a6d49b61e7f 100644 --- a/x-pack/platform/plugins/private/reporting/server/test_helpers/create_mock_reportingplugin.ts +++ b/x-pack/platform/plugins/private/reporting/server/test_helpers/create_mock_reportingplugin.ts @@ -15,8 +15,10 @@ import { docLinksServiceMock, elasticsearchServiceMock, loggingSystemMock, + savedObjectsClientMock, statusServiceMock, } from '@kbn/core/server/mocks'; +import { actionsMock } from '@kbn/actions-plugin/server/mocks'; import { dataPluginMock } from '@kbn/data-plugin/server/mocks'; import { discoverPluginMock } from '@kbn/discover-plugin/server/mocks'; import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; @@ -40,10 +42,22 @@ export const createMockPluginSetup = ( setupMock: Partial> ): ReportingInternalSetup => { return { - encryptedSavedObjects: encryptedSavedObjectsMock.createSetup(), + actions: { + ...actionsMock.createSetup(), + getActionsConfigurationUtilities: jest.fn().mockReturnValue({ + validateEmailAddresses: jest.fn(), + }), + }, + encryptedSavedObjects: encryptedSavedObjectsMock.createSetup({ canEncrypt: true }), features: featuresPluginMock.createSetup(), basePath: { set: jest.fn() }, - router: { get: jest.fn(), post: jest.fn(), put: jest.fn(), delete: jest.fn() }, + router: { + get: jest.fn(), + patch: jest.fn(), + post: jest.fn(), + put: jest.fn(), + delete: jest.fn(), + }, security: securityMock.createSetup(), taskManager: taskManagerMock.createSetup(), logger: loggingSystemMock.createLogger(), @@ -56,6 +70,7 @@ export const createMockPluginSetup = ( const coreSetupMock = coreMock.createSetup(); const coreStartMock = coreMock.createStart(); const logger = loggingSystemMock.createLogger(); +const savedObjectsClient = savedObjectsClientMock.create(); const createMockReportingStore = async (config: ReportingConfigType) => { const mockConfigSchema = createMockConfigSchema(config); @@ -71,7 +86,10 @@ export const createMockPluginStart = async ( return { analytics: coreSetupMock.analytics, esClient: elasticsearchServiceMock.createClusterClient(), - savedObjects: { getScopedClient: jest.fn() }, + savedObjects: { + getScopedClient: jest.fn().mockReturnValue(savedObjectsClient), + createInternalRepository: jest.fn().mockReturnValue(savedObjectsClient), + }, uiSettings: { asScopedToClient: () => ({ get: jest.fn() }) }, discover: discoverPluginMock.createStartContract(), data: dataPluginMock.createStartContract(), diff --git a/x-pack/platform/plugins/private/reporting/server/types.ts b/x-pack/platform/plugins/private/reporting/server/types.ts index e4644380227b..2a9bd9706662 100644 --- a/x-pack/platform/plugins/private/reporting/server/types.ts +++ b/x-pack/platform/plugins/private/reporting/server/types.ts @@ -12,7 +12,7 @@ import type { DiscoverServerPluginStart } from '@kbn/discover-plugin/server'; import type { FeaturesPluginSetup } from '@kbn/features-plugin/server'; import type { FieldFormatsStart } from '@kbn/field-formats-plugin/server'; import type { LicensingPluginStart } from '@kbn/licensing-plugin/server'; -import type { UrlOrUrlLocatorTuple } from '@kbn/reporting-common/types'; +import type { ReportSource, UrlOrUrlLocatorTuple } from '@kbn/reporting-common/types'; import type { ReportApiJSON } from '@kbn/reporting-common/types'; import type { ReportingConfigType } from '@kbn/reporting-server'; import type { ScreenshotModePluginSetup } from '@kbn/screenshot-mode-plugin/server'; @@ -21,18 +21,24 @@ import type { PngScreenshotOptions as BasePngScreenshotOptions, ScreenshottingStart, } from '@kbn/screenshotting-plugin/server'; -import type { SecurityPluginSetup } from '@kbn/security-plugin/server'; +import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/server'; import type { SpacesPluginSetup } from '@kbn/spaces-plugin/server'; import type { + RruleSchedule, TaskManagerSetupContract, TaskManagerStartContract, } from '@kbn/task-manager-plugin/server'; import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; -import type { NotificationsPluginStart } from '@kbn/notifications-plugin/server'; +import type { PluginSetupContract as ActionsPluginSetupContract } from '@kbn/actions-plugin/server'; import { ExportTypesRegistry } from '@kbn/reporting-server/export_types_registry'; import type { AuthenticatedUser } from '@kbn/core-security-common'; import { EncryptedSavedObjectsPluginSetup } from '@kbn/encrypted-saved-objects-plugin/server'; +import type { NotificationsPluginStart } from '@kbn/notifications-plugin/server'; +import { + RawNotification, + RawScheduledReport, +} from './saved_objects/scheduled_report/schemas/latest'; /** * Plugin Setup Contract @@ -50,6 +56,7 @@ export type ReportingUser = { username: AuthenticatedUser['username'] } | false; export type ScrollConfig = ReportingConfigType['csv']['scroll']; export interface ReportingSetupDeps { + actions: ActionsPluginSetupContract; encryptedSavedObjects: EncryptedSavedObjectsPluginSetup; features: FeaturesPluginSetup; screenshotMode: ScreenshotModePluginSetup; @@ -66,6 +73,7 @@ export interface ReportingStartDeps { licensing: LicensingPluginStart; notifications: NotificationsPluginStart; taskManager: TaskManagerStartContract; + security?: SecurityPluginStart; screenshotting?: ScreenshottingStart; } @@ -92,6 +100,44 @@ export interface ReportingJobResponse { job: ReportApiJSON; } +export type ScheduledReportApiJSON = Omit< + ReportSource, + 'attempts' | 'migration_version' | 'output' | 'payload' | 'status' +> & { + id: string; + migration_version?: string; + notification?: RawNotification; + payload: Omit; + schedule: RruleSchedule; +}; + +export interface ScheduledReportingJobResponse { + /** + * Details of a new report job that was requested + * @public + */ + job: ScheduledReportApiJSON; +} + +export type ScheduledReportType = Omit & { + schedule: RruleSchedule; +}; + +export interface ListScheduledReportApiJSON { + id: string; + created_at: RawScheduledReport['createdAt']; + created_by: RawScheduledReport['createdBy']; + enabled: RawScheduledReport['enabled']; + jobtype: RawScheduledReport['jobType']; + last_run: string | undefined; + next_run: string | undefined; + notification: RawScheduledReport['notification']; + payload?: ReportApiJSON['payload']; + schedule: RruleSchedule; + space_id: string; + title: RawScheduledReport['title']; +} + export interface PdfScreenshotOptions extends Omit { urls: UrlOrUrlLocatorTuple[]; } diff --git a/x-pack/platform/plugins/private/reporting/tsconfig.json b/x-pack/platform/plugins/private/reporting/tsconfig.json index c7cfd10ec35e..fcd2883be3b6 100644 --- a/x-pack/platform/plugins/private/reporting/tsconfig.json +++ b/x-pack/platform/plugins/private/reporting/tsconfig.json @@ -5,6 +5,7 @@ }, "include": ["common/**/*", "public/**/*", "server/**/*", "../../../../../typings/**/*"], "kbn_references": [ + "@kbn/actions-plugin", "@kbn/core", "@kbn/data-plugin", "@kbn/discover-plugin", @@ -52,7 +53,11 @@ "@kbn/react-kibana-mount", "@kbn/core-security-common", "@kbn/core-http-server-utils", + "@kbn/core-saved-objects-server", + "@kbn/rrule", "@kbn/notifications-plugin", + "@kbn/spaces-utils", + "@kbn/logging-mocks", ], "exclude": [ "target/**/*", diff --git a/x-pack/platform/plugins/shared/features/server/__snapshots__/oss_features.test.ts.snap b/x-pack/platform/plugins/shared/features/server/__snapshots__/oss_features.test.ts.snap index b8b5910d8392..01a0046f28e8 100644 --- a/x-pack/platform/plugins/shared/features/server/__snapshots__/oss_features.test.ts.snap +++ b/x-pack/platform/plugins/shared/features/server/__snapshots__/oss_features.test.ts.snap @@ -479,7 +479,9 @@ Array [ }, ], "savedObject": Object { - "all": Array [], + "all": Array [ + "scheduled_report", + ], "read": Array [], }, "ui": Array [ @@ -570,7 +572,9 @@ Array [ }, "name": "Generate CSV reports", "savedObject": Object { - "all": Array [], + "all": Array [ + "scheduled_report", + ], "read": Array [], }, "ui": Array [ @@ -646,7 +650,9 @@ Array [ }, ], "savedObject": Object { - "all": Array [], + "all": Array [ + "scheduled_report", + ], "read": Array [], }, "ui": Array [ @@ -706,7 +712,9 @@ Array [ "minimumLicense": "gold", "name": "Generate PDF or PNG reports", "savedObject": Object { - "all": Array [], + "all": Array [ + "scheduled_report", + ], "read": Array [], }, "ui": Array [ @@ -822,7 +830,9 @@ Array [ }, ], "savedObject": Object { - "all": Array [], + "all": Array [ + "scheduled_report", + ], "read": Array [], }, "ui": Array [ @@ -850,7 +860,9 @@ Array [ }, ], "savedObject": Object { - "all": Array [], + "all": Array [ + "scheduled_report", + ], "read": Array [], }, "ui": Array [ @@ -942,7 +954,9 @@ Array [ "minimumLicense": "gold", "name": "Generate PDF or PNG reports", "savedObject": Object { - "all": Array [], + "all": Array [ + "scheduled_report", + ], "read": Array [], }, "ui": Array [ @@ -962,7 +976,9 @@ Array [ }, "name": "Generate CSV reports from Discover session panels", "savedObject": Object { - "all": Array [], + "all": Array [ + "scheduled_report", + ], "read": Array [], }, "ui": Array [ diff --git a/x-pack/platform/plugins/shared/features/server/oss_features.ts b/x-pack/platform/plugins/shared/features/server/oss_features.ts index 39e016301b0e..7f3f815453cf 100644 --- a/x-pack/platform/plugins/shared/features/server/oss_features.ts +++ b/x-pack/platform/plugins/shared/features/server/oss_features.ts @@ -802,7 +802,7 @@ const reportingFeatures: { defaultMessage: 'Generate CSV reports', }), includeIn: 'all', - savedObject: { all: [], read: [] }, + savedObject: { all: ['scheduled_report'], read: [] }, management: { insightsAndAlerting: ['reporting'] }, api: ['generateReport'], ui: ['generateCsv'], @@ -830,7 +830,7 @@ const reportingFeatures: { ), includeIn: 'all', minimumLicense: 'gold', - savedObject: { all: [], read: [] }, + savedObject: { all: ['scheduled_report'], read: [] }, management: { insightsAndAlerting: ['reporting'] }, api: ['generateReport'], ui: ['generateScreenshot'], @@ -844,7 +844,7 @@ const reportingFeatures: { defaultMessage: 'Generate CSV reports from Discover session panels', }), includeIn: 'all', - savedObject: { all: [], read: [] }, + savedObject: { all: ['scheduled_report'], read: [] }, management: { insightsAndAlerting: ['reporting'] }, api: ['downloadCsv'], ui: ['downloadCsv'], @@ -872,7 +872,7 @@ const reportingFeatures: { ), includeIn: 'all', minimumLicense: 'gold', - savedObject: { all: [], read: [] }, + savedObject: { all: ['scheduled_report'], read: [] }, management: { insightsAndAlerting: ['reporting'] }, api: ['generateReport'], ui: ['generateScreenshot'], diff --git a/x-pack/platform/plugins/shared/notifications/server/services/types.ts b/x-pack/platform/plugins/shared/notifications/server/services/types.ts index 8200d3514411..e7dff34825dc 100755 --- a/x-pack/platform/plugins/shared/notifications/server/services/types.ts +++ b/x-pack/platform/plugins/shared/notifications/server/services/types.ts @@ -43,8 +43,9 @@ export interface PlainTextEmail { }; } -export interface AttachmentEmail extends PlainTextEmail { +export interface AttachmentEmail extends Omit { attachments: Attachment[]; + to?: string[]; bcc?: string[]; cc?: string[]; spaceId: string; diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/email/send_email.test.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/email/send_email.test.ts index 3211309bb3ef..213f8eec05c5 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/email/send_email.test.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/email/send_email.test.ts @@ -156,6 +156,70 @@ describe('send_email module', () => { `); }); + test('handles email with attachments', async () => { + const sendEmailOptions = getSendEmailOptions({ transport: { service: 'other' } }); + const result = await sendEmail( + mockLogger, + { + ...sendEmailOptions, + attachments: [ + { + content: 'dGVzdC1vdXRwdXR0ZXN0LW91dHB1dA==', + contentType: 'test-content-type', + encoding: 'base64', + filename: 'report.pdf', + }, + ], + }, + connectorTokenClient, + connectorUsageCollector + ); + expect(result).toBe(sendMailMockResult); + expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "auth": Object { + "pass": "changeme", + "user": "elastic", + }, + "host": undefined, + "port": undefined, + "secure": false, + "tls": Object { + "rejectUnauthorized": true, + }, + }, + ] + `); + expect(sendMailMock.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "attachments": Array [ + Object { + "content": "dGVzdC1vdXRwdXR0ZXN0LW91dHB1dA==", + "contentType": "test-content-type", + "encoding": "base64", + "filename": "report.pdf", + }, + ], + "bcc": Array [], + "cc": Array [ + "bob@example.com", + "robert@example.com", + ], + "from": "fred@example.com", + "html": "

a message

+ ", + "subject": "a subject", + "text": "a message", + "to": Array [ + "jim@example.com", + ], + }, + ] + `); + }); + test('uses OAuth 2.0 Client Credentials authentication for email using "exchange_server" service', async () => { const sendEmailGraphApiMock = sendEmailGraphApi as jest.Mock; const getOAuthClientCredentialsAccessTokenMock = @@ -197,6 +261,7 @@ describe('send_email module', () => { expect(sendEmailGraphApiMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { + "attachments": Array [], "headers": Object { "Authorization": "Bearer dfjsdfgdjhfgsjdf", "Content-Type": "application/json", diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/email/send_email.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/email/send_email.ts index aff892ab27df..d9024af4d42f 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/email/send_email.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/email/send_email.ts @@ -78,6 +78,7 @@ export async function sendEmail( ): Promise { const { transport, content } = options; const { message, messageHTML } = content; + const attachments = options.attachments ?? []; const renderedMessage = messageHTML ?? htmlFromMarkdown(logger, message); @@ -87,10 +88,17 @@ export async function sendEmail( options, renderedMessage, connectorTokenClient, - connectorUsageCollector + connectorUsageCollector, + attachments ); } else { - return await sendEmailWithNodemailer(logger, options, renderedMessage, connectorUsageCollector); + return await sendEmailWithNodemailer( + logger, + options, + renderedMessage, + connectorUsageCollector, + attachments + ); } } @@ -100,7 +108,8 @@ export async function sendEmailWithExchange( options: SendEmailOptions, messageHTML: string, connectorTokenClient: ConnectorTokenClientContract, - connectorUsageCollector: ConnectorUsageCollector + connectorUsageCollector: ConnectorUsageCollector, + attachments: Attachment[] ): Promise { const { transport, configurationUtilities, connectorId } = options; const { clientId, clientSecret, tenantId, oauthTokenUrl } = transport; @@ -167,6 +176,7 @@ export async function sendEmailWithExchange( options, headers, messageHTML, + attachments, }, logger, configurationUtilities, @@ -180,7 +190,8 @@ async function sendEmailWithNodemailer( logger: Logger, options: SendEmailOptions, messageHTML: string, - connectorUsageCollector: ConnectorUsageCollector + connectorUsageCollector: ConnectorUsageCollector, + attachments: Attachment[] ): Promise { const { transport, routing, content, configurationUtilities, hasAuth } = options; const { service } = transport; @@ -197,6 +208,7 @@ async function sendEmailWithNodemailer( subject, html: messageHTML, text: message, + ...(attachments.length > 0 && { attachments }), }; // The transport options do not seem to be exposed as a type, and we reference diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/email/send_email_graph_api.test.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/email/send_email_graph_api.test.ts index 215b977c68d6..5bf874455171 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/email/send_email_graph_api.test.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/email/send_email_graph_api.test.ts @@ -16,7 +16,7 @@ import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.moc import type { CustomHostSettings } from '@kbn/actions-plugin/server/config'; import type { ProxySettings } from '@kbn/actions-plugin/server/types'; import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; -import { sendEmailGraphApi } from './send_email_graph_api'; +import { sendEmailGraphApi, sendEmailWithAttachments } from './send_email_graph_api'; const createAxiosInstanceMock = axios.create as jest.Mock; const axiosInstanceMock = jest.fn(); @@ -24,6 +24,7 @@ const logger = loggingSystemMock.create().get() as jest.Mocked; describe('sendEmailGraphApi', () => { beforeEach(() => { + jest.clearAllMocks(); createAxiosInstanceMock.mockReturnValue(axiosInstanceMock); }); const configurationUtilities = actionsConfigMock.create(); @@ -42,6 +43,7 @@ describe('sendEmailGraphApi', () => { options: getSendEmailOptions(), messageHTML: 'test1', headers: {}, + attachments: [], }, logger, configurationUtilities, @@ -137,12 +139,13 @@ describe('sendEmailGraphApi', () => { options: getSendEmailOptions(), messageHTML: 'test2', headers: { Authorization: 'Bearer 1234567' }, + attachments: [], }, logger, configurationUtilities, connectorUsageCollector ); - expect(axiosInstanceMock.mock.calls[1]).toMatchInlineSnapshot(` + expect(axiosInstanceMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ "https://graph.microsoft.com/v1.0/users/fred@example.com/sendMail", Object { @@ -235,12 +238,13 @@ describe('sendEmailGraphApi', () => { options: getSendEmailOptions(), messageHTML: 'test3', headers: {}, + attachments: [], }, logger, configurationUtilities, connectorUsageCollector ); - expect(axiosInstanceMock.mock.calls[2]).toMatchInlineSnapshot(` + expect(axiosInstanceMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ "https://test/users/fred@example.com/sendMail", Object { @@ -334,7 +338,7 @@ describe('sendEmailGraphApi', () => { await expect( sendEmailGraphApi( - { options: getSendEmailOptions(), messageHTML: 'test1', headers: {} }, + { options: getSendEmailOptions(), messageHTML: 'test1', headers: {}, attachments: [] }, logger, configurationUtilities, connectorUsageCollector @@ -349,6 +353,243 @@ describe('sendEmailGraphApi', () => { ] `); }); + + describe('sendEmailWithAttachments', () => { + test('email adds a small attachment', async () => { + const connectorUsageCollector = new ConnectorUsageCollector({ + logger, + connectorId: 'test-connector-id', + }); + + // create draft + axiosInstanceMock.mockReturnValueOnce({ + status: 201, + data: { id: 'draftId' }, + }); + // add small attachment + axiosInstanceMock.mockReturnValueOnce({ + status: 201, + }); + // send draft + axiosInstanceMock.mockReturnValueOnce({ + status: 202, + }); + const axiosInstance = axios.create(); + + await sendEmailWithAttachments({ + sendEmailOptions: { + options: getSendEmailOptions(), + messageHTML: 'test1', + attachments: [ + { + content: 'dGVzdC1vdXRwdXR0ZXN0LW91dHB1dA==', + contentType: 'test-content-type', + encoding: 'base64', + filename: 'report.pdf', + }, + ], + headers: {}, + }, + logger, + configurationUtilities, + connectorUsageCollector, + axiosInstance, + }); + + expect(axiosInstanceMock).toHaveBeenCalledTimes(3); + + const [createDraftUrl, createDraft] = axiosInstanceMock.mock.calls[0]; + expect(createDraftUrl).toEqual( + 'https://graph.microsoft.com/v1.0/users/fred@example.com/messages' + ); + expect(createDraft.data).toEqual({ + bccRecipients: [], + body: { + content: 'test1', + contentType: 'HTML', + }, + ccRecipients: [ + { + emailAddress: { + address: 'bob@example.com', + }, + }, + { + emailAddress: { + address: 'robert@example.com', + }, + }, + ], + subject: 'a subject', + toRecipients: [ + { + emailAddress: { + address: 'jim@example.com', + }, + }, + ], + }); + + const [addAttachmentUrl, addAttachment] = axiosInstanceMock.mock.calls[1]; + expect(addAttachmentUrl).toEqual( + 'https://graph.microsoft.com/v1.0/users/fred@example.com/messages/draftId/attachments' + ); + expect(addAttachment.data).toEqual({ + '@odata.type': '#microsoft.graph.fileAttachment', + contentBytes: 'dGVzdC1vdXRwdXR0ZXN0LW91dHB1dA==', + contentType: 'test-content-type', + name: 'report.pdf', + }); + + const [sendDraftUrl] = axiosInstanceMock.mock.calls[2]; + expect(sendDraftUrl).toEqual( + 'https://graph.microsoft.com/v1.0/users/fred@example.com/messages/draftId/send' + ); + }); + + test('email uploads a large attachment', async () => { + const connectorUsageCollector = new ConnectorUsageCollector({ + logger, + connectorId: 'test-connector-id', + }); + + // create draft + axiosInstanceMock.mockReturnValueOnce({ + status: 201, + data: { id: 'draftId' }, + }); + // create upload session + axiosInstanceMock.mockReturnValueOnce({ + status: 201, + data: { uploadUrl: 'http://test-upload-session.com' }, + }); + // upload attachment 1/3 + axiosInstanceMock.mockReturnValueOnce({ + status: 200, + }); + // upload attachment 2/3 + axiosInstanceMock.mockReturnValueOnce({ + status: 200, + }); + // upload attachment 3/3 + axiosInstanceMock.mockReturnValueOnce({ + status: 200, + }); + // close upload session + axiosInstanceMock.mockReturnValueOnce({ + status: 204, + }); + // send draft + axiosInstanceMock.mockReturnValueOnce({ + status: 202, + }); + const axiosInstance = axios.create(); + + await sendEmailWithAttachments( + { + sendEmailOptions: { + options: getSendEmailOptions(), + messageHTML: 'test1', + attachments: [ + { + content: 'dGVzdC1vdXRwdXR0ZXN0LW91dHB1dA==', + contentType: 'test-content-type', + encoding: 'base64', + filename: 'report.pdf', + }, + ], + headers: {}, + }, + logger, + configurationUtilities, + connectorUsageCollector, + axiosInstance, + }, + 30, + 10 + ); + expect(axiosInstanceMock).toHaveBeenCalledTimes(7); + + const [createDraftUrl, createDraft] = axiosInstanceMock.mock.calls[0]; + expect(createDraftUrl).toEqual( + 'https://graph.microsoft.com/v1.0/users/fred@example.com/messages' + ); + expect(createDraft.data).toEqual({ + bccRecipients: [], + body: { + content: 'test1', + contentType: 'HTML', + }, + ccRecipients: [ + { + emailAddress: { + address: 'bob@example.com', + }, + }, + { + emailAddress: { + address: 'robert@example.com', + }, + }, + ], + subject: 'a subject', + toRecipients: [ + { + emailAddress: { + address: 'jim@example.com', + }, + }, + ], + }); + + const [createUploadSessionUrl, createUploadSession] = axiosInstanceMock.mock.calls[1]; + expect(createUploadSessionUrl).toEqual( + 'https://graph.microsoft.com/v1.0/users/fred@example.com/messages/draftId/attachments/createUploadSession' + ); + expect(createUploadSession.data).toEqual({ + AttachmentItem: { + attachmentType: 'file', + name: 'report.pdf', + size: 22, + }, + }); + + const [uploadAttachmentUrl1, uploadAttachment1] = axiosInstanceMock.mock.calls[2]; + expect(uploadAttachmentUrl1).toEqual('http://test-upload-session.com'); + expect(uploadAttachment1.data).toBeTruthy(); + expect(uploadAttachment1.headers).toEqual({ + 'Content-Length': '10', + 'Content-Range': 'bytes 0-9/22', + 'Content-Type': 'application/octet-stream', + }); + + const [uploadAttachmentUrl2, uploadAttachment2] = axiosInstanceMock.mock.calls[3]; + expect(uploadAttachmentUrl2).toEqual('http://test-upload-session.com'); + expect(uploadAttachment2.data).toBeTruthy(); + expect(uploadAttachment2.headers).toEqual({ + 'Content-Length': '10', + 'Content-Range': 'bytes 10-19/22', + 'Content-Type': 'application/octet-stream', + }); + + const [uploadAttachmentUrl3, uploadAttachment3] = axiosInstanceMock.mock.calls[4]; + expect(uploadAttachmentUrl3).toEqual('http://test-upload-session.com'); + expect(uploadAttachment3.data).toBeTruthy(); + expect(uploadAttachment3.headers).toEqual({ + 'Content-Length': '2', + 'Content-Range': 'bytes 20-21/22', + 'Content-Type': 'application/octet-stream', + }); + + const [closeUploadSessionUrl] = axiosInstanceMock.mock.calls[5]; + expect(closeUploadSessionUrl).toEqual('http://test-upload-session.com'); + + const [sendDraftUrl] = axiosInstanceMock.mock.calls[6]; + expect(sendDraftUrl).toEqual( + 'https://graph.microsoft.com/v1.0/users/fred@example.com/messages/draftId/send' + ); + }); + }); }); function getSendEmailOptions( diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/email/send_email_graph_api.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/email/send_email_graph_api.ts index db30fe95372e..108a7b379fd7 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/email/send_email_graph_api.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/email/send_email_graph_api.ts @@ -14,6 +14,10 @@ import { request } from '@kbn/actions-plugin/server/lib/axios_utils'; import type { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config'; import type { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; import type { SendEmailOptions } from './send_email'; +import type { Attachment } from '.'; + +const SMALL_ATTACHMENT_LIMIT = 3 * 1024 * 1024; // 3mb +const ATTACHMENT_CHUNK_SIZE = 2 * 1024 * 1024; // 2mb export async function sendEmailGraphApi( sendEmailOptions: SendEmailGraphApiOptions, @@ -22,11 +26,54 @@ export async function sendEmailGraphApi( connectorUsageCollector: ConnectorUsageCollector, axiosInstance?: AxiosInstance ): Promise { - const { options, headers, messageHTML } = sendEmailOptions; - // Create a new axios instance if one is not provided axiosInstance = axiosInstance ?? axios.create(); + const { attachments } = sendEmailOptions; + if (attachments.length > 0) { + logger.debug('[MS Exchange] sending email with attachments'); + return sendEmailWithAttachments({ + sendEmailOptions, + logger, + configurationUtilities, + connectorUsageCollector, + axiosInstance, + }); + } + + return sendEmail({ + sendEmailOptions, + logger, + configurationUtilities, + connectorUsageCollector, + axiosInstance, + }); +} + +interface SendEmailGraphApiOptions { + options: SendEmailOptions; + headers: Record; + messageHTML: string; + attachments: Attachment[]; +} + +interface SendEmailParams { + sendEmailOptions: SendEmailGraphApiOptions; + logger: Logger; + configurationUtilities: ActionsConfigurationUtilities; + connectorUsageCollector: ConnectorUsageCollector; + axiosInstance: AxiosInstance; +} + +async function sendEmail({ + sendEmailOptions, + logger, + configurationUtilities, + connectorUsageCollector, + axiosInstance, +}: SendEmailParams): Promise { + const { options, headers, messageHTML } = sendEmailOptions; + // POST /users/{id | userPrincipalName}/sendMail const res = await request({ axios: axiosInstance, @@ -46,15 +93,58 @@ export async function sendEmailGraphApi( } const errString = stringify(res.data); logger.warn( - `error thrown sending Microsoft Exchange email for clientID: ${sendEmailOptions.options.transport.clientId}: ${errString}` + `error thrown sending Microsoft Exchange email for clientID: ${options.transport.clientId}: ${errString}` ); throw new Error(errString); } -interface SendEmailGraphApiOptions { - options: SendEmailOptions; - headers: Record; - messageHTML: string; +export async function sendEmailWithAttachments( + params: SendEmailParams, + smallAttachmentLimit: number = SMALL_ATTACHMENT_LIMIT, + attachmentChunkSize: number = ATTACHMENT_CHUNK_SIZE +): Promise { + const logger = params.logger.get('ms-exchange'); + logger.debug('Creating draft email'); + const emailId = await createDraft(params); + + const attachments = params.sendEmailOptions.attachments; + for (const attachment of attachments) { + const size = Buffer.byteLength(attachment.content); + if (size < smallAttachmentLimit) { + // If attachment is smaller than the limit, add the attachment to the draft email + logger.debug('Attachment is smaller than 2Mb, attaching to draft'); + await addAttachment(emailId, attachment, params); + } else { + // If attachment is larger than the limit, + // create an upload session and upload attachment in chunks to the draft email + const buffer = Buffer.from(attachment.content, attachment.encoding as BufferEncoding); + const bufferSize = buffer.length; + logger.debug('Attachment is larger than 2Mb, creating upload session'); + const uploadUrl = await createUploadSession(emailId, attachment.filename, bufferSize, params); + logger.debug(`UploadUrl: ${uploadUrl}`); + + const chunks = getAttachmentChunks(buffer, bufferSize, attachmentChunkSize); + let start = 0; + let count = 1; + for (const chunk of chunks) { + const end = start + chunk.length - 1; + const headers = { + 'Content-Type': 'application/octet-stream', + 'Content-Length': `${chunk.length}`, + 'Content-Range': `bytes ${start}-${end}/${bufferSize}`, + }; + logger.debug(`Uploading chunk ${count} of ${chunks.length}`); + await uploadAttachmentChunk(uploadUrl, chunk, headers, params); + start = start + chunk.length; + count++; + } + logger.debug('Closing upload session'); + await closeUploadSession(uploadUrl, params); + } + } + + logger.debug('Sending draft email'); + return sendDraft(emailId, params); } function getMessage(emailOptions: SendEmailOptions, messageHTML: string) { @@ -86,3 +176,254 @@ function getMessage(emailOptions: SendEmailOptions, messageHTML: string) { }, }; } + +function getAttachmentChunks(buffer: Buffer, size: number, attachmentChunkSize: number): Buffer[] { + const chunks: Buffer[] = []; + let start = 0; + while (start < size) { + const end = Math.min(start + attachmentChunkSize, size); + const chunk = buffer.subarray(start, end); + chunks.push(chunk); + start = end; + } + return chunks; +} + +async function createDraft({ + sendEmailOptions, + logger, + configurationUtilities, + connectorUsageCollector, + axiosInstance, +}: SendEmailParams): Promise { + const { options, headers, messageHTML } = sendEmailOptions; + + // POST /users/{id | userPrincipalName}/messages + const { message } = getMessage(options, messageHTML); + const res = await request({ + axios: axiosInstance, + url: `${configurationUtilities.getMicrosoftGraphApiUrl()}/users/${ + options.routing.from + }/messages`, + method: 'post', + logger, + data: message, + headers, + configurationUtilities, + validateStatus: () => true, + connectorUsageCollector, + }); + + if (res.status !== 201) { + const errString = stringify(res.data); + logger.warn( + `error thrown creating Microsoft Exchange email with attachments for clientID: ${options.transport.clientId}: ${errString}` + ); + throw new Error(errString); + } + return res.data.id; +} + +async function sendDraft( + emailId: string, + { + sendEmailOptions, + logger, + configurationUtilities, + connectorUsageCollector, + axiosInstance, + }: SendEmailParams +): Promise { + const { options, headers } = sendEmailOptions; + + // POST /users/{id | userPrincipalName}/messages/{emailId}/send + const res = await request({ + axios: axiosInstance, + url: `${configurationUtilities.getMicrosoftGraphApiUrl()}/users/${ + options.routing.from + }/messages/${emailId}/send`, + method: 'post', + logger, + data: {}, + headers, + configurationUtilities, + validateStatus: () => true, + connectorUsageCollector, + }); + if (res.status === 202) { + return res.data; + } + const errString = stringify(res.data); + logger.warn( + `error thrown sending Microsoft Exchange email with attachments for clientID: ${options.transport.clientId}: ${errString}` + ); + throw new Error(errString); +} + +async function createUploadSession( + emailId: string, + name: string, + size: number, + { + sendEmailOptions, + logger, + configurationUtilities, + connectorUsageCollector, + axiosInstance, + }: SendEmailParams +): Promise { + const { options, headers } = sendEmailOptions; + + // POST /users/{id | userPrincipalName}/messages/{emailId}/attachments/createUploadSession + const res = await request({ + axios: axiosInstance, + url: `${configurationUtilities.getMicrosoftGraphApiUrl()}/users/${ + options.routing.from + }/messages/${emailId}/attachments/createUploadSession`, + method: 'post', + logger, + data: { + AttachmentItem: { + attachmentType: 'file', + name, + size, + }, + }, + headers, + configurationUtilities, + validateStatus: () => true, + connectorUsageCollector, + }); + if (res.status !== 201) { + const errString = stringify(res.data); + logger.warn( + `error thrown creating Microsoft Exchange attachment upload session for clientID: ${options.transport.clientId}: ${errString}` + ); + throw new Error(errString); + } + return res.data.uploadUrl; +} + +async function closeUploadSession( + uploadUrl: string, + { + sendEmailOptions, + logger, + configurationUtilities, + connectorUsageCollector, + axiosInstance, + }: SendEmailParams +): Promise { + const { options } = sendEmailOptions; + + const res = await request({ + axios: axiosInstance, + url: uploadUrl, + method: 'delete', + logger, + configurationUtilities, + validateStatus: () => true, + connectorUsageCollector, + }); + if (res.status === 204) { + return res.data; + } + const errString = stringify(`${res.status} ${res.statusText}`); + logger.warn( + `error thrown closing Microsoft Exchange attachment upload session for clientID: ${options.transport.clientId}: ${errString}` + ); + throw new Error(errString); +} + +async function addAttachment( + emailId: string, + attachment: Attachment, + { + sendEmailOptions, + logger, + configurationUtilities, + connectorUsageCollector, + axiosInstance, + }: SendEmailParams +): Promise { + const { options, headers } = sendEmailOptions; + const responseSettings = configurationUtilities.getResponseSettings(); + + // POST /users/{id | userPrincipalName}/messages/{emailId}/attachments + const res = await request({ + axios: axiosInstance, + url: `${configurationUtilities.getMicrosoftGraphApiUrl()}/users/${ + options.routing.from + }/messages/${emailId}/attachments`, + method: 'post', + logger, + data: { + '@odata.type': '#microsoft.graph.fileAttachment', + name: attachment.filename, + contentType: attachment.contentType, + contentBytes: attachment.content, + }, + headers, + configurationUtilities: { + ...configurationUtilities, + // override maxContentLength config for requests with attachments + getResponseSettings: () => ({ + ...responseSettings, + maxContentLength: SMALL_ATTACHMENT_LIMIT, + }), + }, + validateStatus: () => true, + connectorUsageCollector, + }); + if (res.status === 201) { + return res.data; + } + const errString = stringify(res.data); + logger.warn( + `error thrown adding attachment to Microsoft Exchange email for clientID: ${options.transport.clientId}: ${errString}` + ); + throw new Error(errString); +} + +async function uploadAttachmentChunk( + uploadUrl: string, + chunk: Buffer, + headers: Record, + { + sendEmailOptions, + logger, + configurationUtilities, + connectorUsageCollector, + axiosInstance, + }: SendEmailParams +): Promise { + const { options } = sendEmailOptions; + const responseSettings = configurationUtilities.getResponseSettings(); + + const res = await request({ + axios: axiosInstance, + url: uploadUrl, + method: 'put', + logger, + data: chunk, + headers, + configurationUtilities: { + ...configurationUtilities, + // Override maxContentLength config for requests with attachments + getResponseSettings: () => ({ + ...responseSettings, + maxContentLength: SMALL_ATTACHMENT_LIMIT, + }), + }, + validateStatus: () => true, + connectorUsageCollector, + }); + + if (res.status !== 200 && res.status !== 201) { + const errString = stringify(res.data); + logger.warn( + `error thrown uploading attachment to Microsoft Exchange email for clientID: ${options.transport.clientId}: ${errString}` + ); + throw new Error(errString); + } +} diff --git a/x-pack/platform/plugins/shared/task_manager/server/constants.ts b/x-pack/platform/plugins/shared/task_manager/server/constants.ts index 86238e44ae64..3fa3ac2489b7 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/constants.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/constants.ts @@ -16,4 +16,5 @@ export const CONCURRENCY_ALLOW_LIST_BY_TASK_TYPE: string[] = [ // task types requiring a concurrency 'report:execute', + 'report:execute-scheduled', ]; diff --git a/x-pack/platform/plugins/shared/task_manager/server/index.ts b/x-pack/platform/plugins/shared/task_manager/server/index.ts index 6678d4c08ca1..a20f3bf83b41 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/index.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/index.ts @@ -24,7 +24,9 @@ export type { } from './task'; export { Frequency, Weekday } from '@kbn/rrule'; +export { scheduleRruleSchema } from './saved_objects'; +export type { RruleSchedule } from './task'; export { TaskStatus, TaskPriority, TaskCost } from './task'; export type { TaskRegisterDefinition, TaskDefinitionRegistry } from './task_type_dictionary'; diff --git a/x-pack/platform/plugins/shared/task_manager/server/lib/get_first_run_at.test.ts b/x-pack/platform/plugins/shared/task_manager/server/lib/get_first_run_at.test.ts index d3e562c67b72..e030aa9c61e8 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/lib/get_first_run_at.test.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/lib/get_first_run_at.test.ts @@ -159,7 +159,7 @@ describe('getFirstRunAt', () => { freq: 2, // Weekly interval: 1, tzid: 'UTC', - byweekday: [1], // Monday + byweekday: ['1'], // Monday }, }, }; @@ -182,7 +182,7 @@ describe('getFirstRunAt', () => { freq: 2, // Weekly interval: 1, tzid: 'UTC', - byweekday: [1], // Monday + byweekday: ['MO'], // Monday byhour: [12], byminute: [15], }, @@ -257,7 +257,7 @@ describe('getFirstRunAt', () => { freq: 1, // Monthly interval: 1, tzid: 'UTC', - byweekday: [3], // Wednesday + byweekday: ['3'], // Wednesday byhour: [12], byminute: [17], }, diff --git a/x-pack/platform/plugins/shared/task_manager/server/saved_objects/index.ts b/x-pack/platform/plugins/shared/task_manager/server/saved_objects/index.ts index 9994426fb083..75b200acc38b 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/saved_objects/index.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/saved_objects/index.ts @@ -14,6 +14,8 @@ import { getOldestIdleActionTask } from '../queries/oldest_idle_action_task'; import { TASK_MANAGER_INDEX } from '../constants'; import { backgroundTaskNodeModelVersions, taskModelVersions } from './model_versions'; +export { scheduleRruleSchema } from './schemas/task'; + export const TASK_SO_NAME = 'task'; export const BACKGROUND_TASK_NODE_SO_NAME = 'background-task-node'; diff --git a/x-pack/platform/plugins/shared/task_manager/server/saved_objects/schemas/task.ts b/x-pack/platform/plugins/shared/task_manager/server/saved_objects/schemas/task.ts index 365cc506cb12..874f374355bb 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/saved_objects/schemas/task.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/saved_objects/schemas/task.ts @@ -55,6 +55,14 @@ export const taskSchemaV3 = taskSchemaV2.extends({ priority: schema.maybe(schema.number()), }); +export const scheduleIntervalSchema = schema.object({ + interval: schema.string({ validate: validateDuration }), +}); + +export const scheduleRruleSchema = schema.object({ + rrule: rruleSchedule, +}); + export const taskSchemaV4 = taskSchemaV3.extends({ apiKey: schema.maybe(schema.string()), userScope: schema.maybe( @@ -67,14 +75,5 @@ export const taskSchemaV4 = taskSchemaV3.extends({ }); export const taskSchemaV5 = taskSchemaV4.extends({ - schedule: schema.maybe( - schema.oneOf([ - schema.object({ - interval: schema.string({ validate: validateDuration }), - }), - schema.object({ - rrule: rruleSchedule, - }), - ]) - ), + schedule: schema.maybe(schema.oneOf([scheduleIntervalSchema, scheduleRruleSchema])), }); diff --git a/x-pack/platform/plugins/shared/task_manager/server/task.ts b/x-pack/platform/plugins/shared/task_manager/server/task.ts index 2e22f5be2b4a..b9a59e345226 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/task.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/task.ts @@ -11,7 +11,7 @@ import type { ObjectType, TypeOf } from '@kbn/config-schema'; import { schema } from '@kbn/config-schema'; import { isNumber } from 'lodash'; import type { KibanaRequest } from '@kbn/core/server'; -import type { Frequency, Weekday } from '@kbn/rrule'; +import type { Frequency } from '@kbn/rrule'; import { isErr, tryAsResult } from './lib/result_type'; import type { Interval } from './lib/intervals'; import { isInterval, parseIntervalAsMillisecond } from './lib/intervals'; @@ -259,8 +259,9 @@ export interface IntervalSchedule { rrule?: never; } +export type Rrule = RruleMonthly | RruleWeekly | RruleDaily; export interface RruleSchedule { - rrule: RruleMonthly | RruleWeekly | RruleDaily; + rrule: Rrule; interval?: never; } @@ -269,17 +270,16 @@ interface RruleCommon { interval: number; tzid: string; } - interface RruleMonthly extends RruleCommon { freq: Frequency.MONTHLY; bymonthday?: number[]; byhour?: number[]; byminute?: number[]; - byweekday?: Weekday[]; + byweekday?: string[]; } interface RruleWeekly extends RruleCommon { freq: Frequency.WEEKLY; - byweekday?: Weekday[]; + byweekday?: string[]; byhour?: number[]; byminute?: number[]; bymonthday?: never; @@ -288,7 +288,7 @@ interface RruleDaily extends RruleCommon { freq: Frequency.DAILY; byhour?: number[]; byminute?: number[]; - byweekday?: Weekday[]; + byweekday?: string[]; bymonthday?: never; } diff --git a/x-pack/platform/plugins/shared/task_manager/server/task_type_dictionary.ts b/x-pack/platform/plugins/shared/task_manager/server/task_type_dictionary.ts index cf21ad4c70ff..84415cc81a0c 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/task_type_dictionary.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/task_type_dictionary.ts @@ -35,6 +35,9 @@ export const REMOVED_TYPES: string[] = [ export const SHARED_CONCURRENCY_TASKS: string[][] = [ // for testing ['sampleTaskSharedConcurrencyType1', 'sampleTaskSharedConcurrencyType2'], + + // reporting + ['report:execute', 'report:execute-scheduled'], ]; /** diff --git a/x-pack/platform/test/api_integration/apis/features/features/features.ts b/x-pack/platform/test/api_integration/apis/features/features/features.ts index cef238e8dc28..7386d58ed939 100644 --- a/x-pack/platform/test/api_integration/apis/features/features/features.ts +++ b/x-pack/platform/test/api_integration/apis/features/features/features.ts @@ -127,6 +127,7 @@ export default function ({ getService }: FtrProviderContext) { 'inventory', 'logs', 'maintenanceWindow', + 'manageReporting', 'maps_v2', 'osquery', 'rulesSettings', @@ -210,6 +211,7 @@ export default function ({ getService }: FtrProviderContext) { 'fleet', 'fleetv2', 'entityManager', + 'manageReporting', ]; const features = body.filter( diff --git a/x-pack/platform/test/api_integration/apis/security/privileges.ts b/x-pack/platform/test/api_integration/apis/security/privileges.ts index 9af6fd1b3df8..e82bfd81a8a1 100644 --- a/x-pack/platform/test/api_integration/apis/security/privileges.ts +++ b/x-pack/platform/test/api_integration/apis/security/privileges.ts @@ -208,6 +208,7 @@ export default function ({ getService }: FtrProviderContext) { infrastructure: ['all', 'read', 'minimal_all', 'minimal_read'], logs: ['all', 'read', 'minimal_all', 'minimal_read'], dataQuality: ['all', 'read', 'minimal_all', 'minimal_read'], + manageReporting: ['all', 'read', 'minimal_all', 'minimal_read'], apm: ['all', 'read', 'minimal_all', 'minimal_read', 'settings_save'], discover: [ 'all', diff --git a/x-pack/platform/test/api_integration_basic/apis/security/privileges.ts b/x-pack/platform/test/api_integration_basic/apis/security/privileges.ts index 1a4e99fc1a73..1f285475c70a 100644 --- a/x-pack/platform/test/api_integration_basic/apis/security/privileges.ts +++ b/x-pack/platform/test/api_integration_basic/apis/security/privileges.ts @@ -81,6 +81,7 @@ export default function ({ getService }: FtrProviderContext) { aiAssistantManagementSelection: ['all', 'read', 'minimal_all', 'minimal_read'], inventory: ['all', 'read', 'minimal_all', 'minimal_read'], dataQuality: ['all', 'read', 'minimal_all', 'minimal_read'], + manageReporting: ['all', 'read', 'minimal_all', 'minimal_read'], entityManager: ['all', 'read', 'minimal_all', 'minimal_read'], }, global: ['all', 'read'], @@ -312,6 +313,7 @@ export default function ({ getService }: FtrProviderContext) { infrastructure: ['all', 'read', 'minimal_all', 'minimal_read'], logs: ['all', 'read', 'minimal_all', 'minimal_read'], dataQuality: ['all', 'read', 'minimal_all', 'minimal_read'], + manageReporting: ['all', 'read', 'minimal_all', 'minimal_read'], apm: ['all', 'read', 'minimal_all', 'minimal_read', 'settings_save'], discover: [ 'all', diff --git a/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts b/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts index 6b46e1548039..7eeded4099ad 100644 --- a/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts +++ b/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts @@ -171,6 +171,7 @@ export default function ({ getService }: FtrProviderContext) { 'osquery:telemetry-packs', 'osquery:telemetry-saved-queries', 'report:execute', + 'report:execute-scheduled', 'risk_engine:risk_scoring', 'search:agentless-connectors-manager', 'security-solution-ea-asset-criticality-ecs-migration', diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/disable_scheduled_reports.ts b/x-pack/test/reporting_api_integration/reporting_and_security/disable_scheduled_reports.ts new file mode 100644 index 000000000000..e7248029c201 --- /dev/null +++ b/x-pack/test/reporting_api_integration/reporting_and_security/disable_scheduled_reports.ts @@ -0,0 +1,167 @@ +/* + * 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 { SerializedSearchSourceFields } from '@kbn/data-plugin/common'; +import { FtrProviderContext } from '../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const reportingAPI = getService('reportingAPI'); + + describe('Disable Scheduled Reports', () => { + const scheduledReportIds: string[] = []; + + before(async () => { + await reportingAPI.initEcommerce(); + }); + + after(async () => { + await reportingAPI.teardownEcommerce(); + await reportingAPI.deleteAllReports(); + await reportingAPI.deleteScheduledReports(scheduledReportIds); + await reportingAPI.deleteTasks(scheduledReportIds); + }); + + it('should allow reporting user to disable their own scheduled report', async () => { + const report = await reportingAPI.schedulePdf( + reportingAPI.REPORTING_USER_USERNAME, + reportingAPI.REPORTING_USER_PASSWORD, + { + browserTimezone: 'UTC', + title: 'test PDF allowed', + layout: { id: 'preserve_layout' }, + locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }], + objectType: 'dashboard', + version: '7.14.0', + } + ); + + expect(report.status).to.eql(200); + const reportId = report.body.job.id; + + scheduledReportIds.push(reportId); + + // report created by reporting user, reporting user should be able to disable + const res = await reportingAPI.disableScheduledReports( + [reportId], + reportingAPI.REPORTING_USER_USERNAME, + reportingAPI.REPORTING_USER_PASSWORD + ); + + expect(res).to.eql({ scheduled_report_ids: [reportId], errors: [], total: 1 }); + + const soResult = await reportingAPI.getScheduledReports(reportId); + expect(soResult.body._source.scheduled_report.enabled).to.eql(false); + const taskResult = await reportingAPI.getTask(reportId); + expect(taskResult.body._source?.task.enabled).to.eql(false); + }); + + it('should not allow user to disable other users reports when no ManageReporting feature privilege', async () => { + const report = await reportingAPI.schedulePdf( + reportingAPI.MANAGE_REPORTING_USER_USERNAME, + reportingAPI.MANAGE_REPORTING_USER_PASSWORD, + { + browserTimezone: 'UTC', + title: 'test PDF allowed', + layout: { id: 'preserve_layout' }, + locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }], + objectType: 'visualization', + version: '7.14.0', + } + ); + + expect(report.status).to.eql(200); + const reportId = report.body.job.id; + + scheduledReportIds.push(reportId); + + // report created by manage reporting user, reporting user should not be able to disable + const res = await reportingAPI.disableScheduledReports( + [reportId], + reportingAPI.REPORTING_USER_USERNAME, + reportingAPI.REPORTING_USER_PASSWORD + ); + + expect(res).to.eql({ + scheduled_report_ids: [], + errors: [ + { + message: `Not found.`, + status: 404, + id: reportId, + }, + ], + total: 1, + }); + + const soResult = await reportingAPI.getScheduledReports(reportId); + expect(soResult.body._source.scheduled_report.enabled).to.eql(true); + const taskResult = await reportingAPI.getTask(reportId); + expect(taskResult.body._source?.task.enabled).to.eql(true); + }); + + it('should allow user to disable other users reports when they have ManageReporting feature privilege', async () => { + const report1 = await reportingAPI.scheduleCsv( + { + browserTimezone: 'UTC', + title: 'allowed search', + objectType: 'search', + searchSource: { + version: true, + fields: [{ field: '*', include_unmapped: true }], + index: '5193f870-d861-11e9-a311-0fa548c5f953', + } as unknown as SerializedSearchSourceFields, + columns: [], + version: '7.13.0', + }, + reportingAPI.REPORTING_USER_USERNAME, + reportingAPI.REPORTING_USER_PASSWORD + ); + + const report2 = await reportingAPI.schedulePdf( + reportingAPI.MANAGE_REPORTING_USER_USERNAME, + reportingAPI.MANAGE_REPORTING_USER_PASSWORD, + { + browserTimezone: 'UTC', + title: 'test PDF allowed', + layout: { id: 'preserve_layout' }, + locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }], + objectType: 'visualization', + version: '7.14.0', + } + ); + + expect(report1.status).to.eql(200); + expect(report2.status).to.eql(200); + + const report1Id = report1.body.job.id; + const report2Id = report2.body.job.id; + + scheduledReportIds.push(report1Id); + scheduledReportIds.push(report2Id); + + // manage reporting user should be able to disable their own report and reporting user report + const res = await reportingAPI.disableScheduledReports( + [report1Id, report2Id], + reportingAPI.MANAGE_REPORTING_USER_USERNAME, + reportingAPI.MANAGE_REPORTING_USER_PASSWORD + ); + + expect(res).to.eql({ scheduled_report_ids: [report1Id, report2Id], errors: [], total: 2 }); + + const soResult1 = await reportingAPI.getScheduledReports(report1Id); + expect(soResult1.body._source.scheduled_report.enabled).to.eql(false); + const soResult2 = await reportingAPI.getScheduledReports(report2Id); + expect(soResult2.body._source.scheduled_report.enabled).to.eql(false); + const taskResult1 = await reportingAPI.getTask(report1Id); + expect(taskResult1.body._source?.task.enabled).to.eql(false); + const taskResult2 = await reportingAPI.getTask(report2Id); + expect(taskResult2.body._source?.task.enabled).to.eql(false); + }); + }); +} diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/index.ts b/x-pack/test/reporting_api_integration/reporting_and_security/index.ts index fedad1bf589f..e3d8a55ac96d 100644 --- a/x-pack/test/reporting_api_integration/reporting_and_security/index.ts +++ b/x-pack/test/reporting_api_integration/reporting_and_security/index.ts @@ -17,6 +17,8 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { await reportingAPI.createTestReportingUserRole(); await reportingAPI.createDataAnalyst(); await reportingAPI.createTestReportingUser(); + await reportingAPI.createManageReportingUserRole(); + await reportingAPI.createManageReportingUser(); }); loadTestFile(require.resolve('./bwc_existing_indexes')); @@ -25,6 +27,8 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./ilm_migration_apis')); loadTestFile(require.resolve('./security_roles_privileges')); loadTestFile(require.resolve('./spaces')); + loadTestFile(require.resolve('./list_scheduled_reports')); + loadTestFile(require.resolve('./disable_scheduled_reports')); loadTestFile(require.resolve('./list_jobs')); // CSV-specific diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/list_scheduled_reports.ts b/x-pack/test/reporting_api_integration/reporting_and_security/list_scheduled_reports.ts new file mode 100644 index 000000000000..0eca98c4d7aa --- /dev/null +++ b/x-pack/test/reporting_api_integration/reporting_and_security/list_scheduled_reports.ts @@ -0,0 +1,131 @@ +/* + * 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 { SerializedSearchSourceFields } from '@kbn/data-plugin/common'; +import { JobParamsPDFV2 } from '@kbn/reporting-export-types-pdf-common'; +import { JobParamsCSV } from '@kbn/reporting-export-types-csv-common'; +import { FtrProviderContext } from '../ftr_provider_context'; + +const pdfPayload: JobParamsPDFV2 = { + browserTimezone: 'UTC', + title: 'test PDF allowed', + layout: { id: 'preserve_layout' }, + locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }], + objectType: 'dashboard', + version: '7.14.0', +}; + +const csvPayload: JobParamsCSV = { + browserTimezone: 'UTC', + title: 'allowed search', + objectType: 'search', + searchSource: { + version: true, + fields: [{ field: '*', include_unmapped: true }], + index: '5193f870-d861-11e9-a311-0fa548c5f953', + } as unknown as SerializedSearchSourceFields, + columns: [], + version: '7.13.0', +}; +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const reportingAPI = getService('reportingAPI'); + + describe('List Scheduled Reports', () => { + let report1Id: string; + let report2Id: string; + let report3Id: string; + const scheduledReportIds: string[] = []; + + before(async () => { + await reportingAPI.initEcommerce(); + + const report1 = await reportingAPI.schedulePdf( + reportingAPI.REPORTING_USER_USERNAME, + reportingAPI.REPORTING_USER_PASSWORD, + pdfPayload + ); + + const report2 = await reportingAPI.scheduleCsv( + csvPayload, + reportingAPI.REPORTING_USER_USERNAME, + reportingAPI.REPORTING_USER_PASSWORD + ); + + const report3 = await reportingAPI.schedulePdf( + reportingAPI.MANAGE_REPORTING_USER_USERNAME, + reportingAPI.MANAGE_REPORTING_USER_PASSWORD, + pdfPayload + ); + + expect(report1.status).to.eql(200); + expect(report2.status).to.eql(200); + expect(report3.status).to.eql(200); + + report1Id = report1.body.job.id; + report2Id = report2.body.job.id; + report3Id = report3.body.job.id; + + scheduledReportIds.push(report1Id); + scheduledReportIds.push(report2Id); + scheduledReportIds.push(report3Id); + }); + + after(async () => { + await reportingAPI.teardownEcommerce(); + await reportingAPI.deleteAllReports(); + await reportingAPI.deleteScheduledReports(scheduledReportIds); + await reportingAPI.deleteTasks(scheduledReportIds); + }); + + it('should only return reports scheduled by the user when user does not have ManageReporting feature privilege', async () => { + const res = await reportingAPI.listScheduledReports( + reportingAPI.REPORTING_USER_USERNAME, + reportingAPI.REPORTING_USER_PASSWORD + ); + + expect(res.total).to.eql(2); + + for (const report of res.data) { + expect(report.created_by).to.eql(reportingAPI.REPORTING_USER_USERNAME); + expect([report1Id, report2Id]).to.contain(report.id); + expect(report.next_run).not.to.be(undefined); + expect(report.space_id).to.eql('default'); + expect(report.schedule).to.have.property('rrule'); + expect(report.enabled).to.be(true); + expect(report.payload).to.have.property('objectType'); + expect(report.payload).to.have.property('browserTimezone'); + expect(report.payload).to.have.property('title'); + } + }); + + it('should return reports scheduled by all users when user has ManageReporting feature privilege', async () => { + const res = await reportingAPI.listScheduledReports( + reportingAPI.MANAGE_REPORTING_USER_USERNAME, + reportingAPI.MANAGE_REPORTING_USER_PASSWORD + ); + + expect(res.total).to.eql(3); + + for (const report of res.data) { + expect([ + reportingAPI.REPORTING_USER_USERNAME, + reportingAPI.MANAGE_REPORTING_USER_USERNAME, + ]).to.contain(report.created_by); + expect([report1Id, report2Id, report3Id]).to.contain(report.id); + expect(report.next_run).not.to.be(undefined); + expect(report.space_id).to.eql('default'); + expect(report.schedule).to.have.property('rrule'); + expect(report.enabled).to.be(true); + expect(report.payload).to.have.property('objectType'); + expect(report.payload).to.have.property('browserTimezone'); + expect(report.payload).to.have.property('title'); + } + }); + }); +} diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/security_roles_privileges.ts b/x-pack/test/reporting_api_integration/reporting_and_security/security_roles_privileges.ts index 0b5237a9051d..223da4400260 100644 --- a/x-pack/test/reporting_api_integration/reporting_and_security/security_roles_privileges.ts +++ b/x-pack/test/reporting_api_integration/reporting_and_security/security_roles_privileges.ts @@ -7,6 +7,8 @@ import expect from '@kbn/expect'; import { SerializedSearchSourceFields } from '@kbn/data-plugin/common'; +import { SearchHit } from '@elastic/elasticsearch/lib/api/types'; +import { SerializedConcreteTaskInstance } from '@kbn/task-manager-plugin/server/task'; import { FtrProviderContext } from '../ftr_provider_context'; // eslint-disable-next-line import/no-default-export @@ -14,13 +16,33 @@ export default function ({ getService }: FtrProviderContext) { const reportingAPI = getService('reportingAPI'); const supertest = getService('supertest'); + function testExpectedTask( + id: string, + jobtype: string, + task: SearchHit<{ task: SerializedConcreteTaskInstance }> + ) { + expect(task._source?.task.taskType).to.eql('report:execute-scheduled'); + + const params = JSON.parse(task._source?.task.params ?? ''); + expect(params.id).to.eql(id); + expect(params.jobtype).to.eql(jobtype); + + expect(task._source?.task.apiKey).not.to.be(undefined); + expect(task._source?.task.schedule?.rrule).not.to.be(undefined); + + expect(task._source?.task.schedule?.interval).to.be(undefined); + } describe('Security Roles and Privileges for Applications', () => { + const scheduledReportIds: string[] = []; + const scheduledReportTaskIds: string[] = []; before(async () => { await reportingAPI.initEcommerce(); }); after(async () => { await reportingAPI.teardownEcommerce(); await reportingAPI.deleteAllReports(); + await reportingAPI.deleteScheduledReports(scheduledReportIds); + await reportingAPI.deleteTasks(scheduledReportTaskIds); }); describe('Dashboard: Generate PDF report', () => { @@ -162,6 +184,185 @@ export default function ({ getService }: FtrProviderContext) { }); }); + describe('Dashboard: Schedule PDF report', () => { + it('does not allow user that does not have the role-based privilege', async () => { + const res = await reportingAPI.schedulePdf( + reportingAPI.DATA_ANALYST_USERNAME, + reportingAPI.DATA_ANALYST_PASSWORD, + { + browserTimezone: 'UTC', + title: 'test PDF disallowed', + layout: { id: 'preserve_layout' }, + locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }], + objectType: 'dashboard', + version: '7.14.0', + } + ); + expect(res.status).to.eql(403); + }); + + it('does allow user with the role-based privilege', async () => { + const res = await reportingAPI.schedulePdf( + reportingAPI.REPORTING_USER_USERNAME, + reportingAPI.REPORTING_USER_PASSWORD, + { + browserTimezone: 'UTC', + title: 'test PDF allowed', + layout: { id: 'preserve_layout' }, + locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }], + objectType: 'dashboard', + version: '7.14.0', + } + ); + expect(res.status).to.eql(200); + + const soResult = await reportingAPI.getScheduledReports(res.body.job.id); + expect(soResult.status).to.eql(200); + expect(soResult.body._source.scheduled_report.title).to.eql('test PDF allowed'); + scheduledReportIds.push(res.body.job.id); + + const taskResult = await reportingAPI.getTask(res.body.job.id); + expect(taskResult.status).to.eql(200); + testExpectedTask(res.body.job.id, 'printable_pdf_v2', taskResult.body); + scheduledReportTaskIds.push(res.body.job.id); + }); + }); + + describe('Visualize: Schedule PDF report', () => { + it('does not allow user that does not have the role-based privilege', async () => { + const res = await reportingAPI.schedulePdf( + reportingAPI.DATA_ANALYST_USERNAME, + reportingAPI.DATA_ANALYST_PASSWORD, + { + browserTimezone: 'UTC', + title: 'test PDF disallowed', + layout: { id: 'preserve_layout' }, + locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }], + objectType: 'visualization', + version: '7.14.0', + } + ); + expect(res.status).to.eql(403); + }); + + it('does allow user with the role-based privilege', async () => { + const res = await reportingAPI.schedulePdf( + reportingAPI.REPORTING_USER_USERNAME, + reportingAPI.REPORTING_USER_PASSWORD, + { + browserTimezone: 'UTC', + title: 'test PDF allowed', + layout: { id: 'preserve_layout' }, + locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }], + objectType: 'visualization', + version: '7.14.0', + } + ); + expect(res.status).to.eql(200); + + const soResult = await reportingAPI.getScheduledReports(res.body.job.id); + expect(soResult.status).to.eql(200); + expect(soResult.body._source.scheduled_report.title).to.eql('test PDF allowed'); + scheduledReportIds.push(res.body.job.id); + + const taskResult = await reportingAPI.getTask(res.body.job.id); + expect(taskResult.status).to.eql(200); + testExpectedTask(res.body.job.id, 'printable_pdf_v2', taskResult.body); + scheduledReportTaskIds.push(res.body.job.id); + }); + }); + + describe('Canvas: Schedule PDF report', () => { + it('does not allow user that does not have the role-based privilege', async () => { + const res = await reportingAPI.schedulePdf( + reportingAPI.DATA_ANALYST_USERNAME, + reportingAPI.DATA_ANALYST_PASSWORD, + { + browserTimezone: 'UTC', + title: 'test PDF disallowed', + layout: { id: 'preserve_layout' }, + locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }], + objectType: 'canvas', + version: '7.14.0', + } + ); + expect(res.status).to.eql(403); + }); + + it('does allow user with the role-based privilege', async () => { + const res = await reportingAPI.schedulePdf( + reportingAPI.REPORTING_USER_USERNAME, + reportingAPI.REPORTING_USER_PASSWORD, + { + browserTimezone: 'UTC', + title: 'test PDF allowed', + layout: { id: 'preserve_layout' }, + locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }], + objectType: 'canvas', + version: '7.14.0', + } + ); + expect(res.status).to.eql(200); + + const soResult = await reportingAPI.getScheduledReports(res.body.job.id); + expect(soResult.status).to.eql(200); + expect(soResult.body._source.scheduled_report.title).to.eql('test PDF allowed'); + scheduledReportIds.push(res.body.job.id); + + const taskResult = await reportingAPI.getTask(res.body.job.id); + expect(taskResult.status).to.eql(200); + testExpectedTask(res.body.job.id, 'printable_pdf_v2', taskResult.body); + scheduledReportTaskIds.push(res.body.job.id); + }); + }); + + describe('Discover: Schedule CSV report', () => { + it('does not allow user that does not have the role-based privilege', async () => { + const res = await reportingAPI.scheduleCsv( + { + browserTimezone: 'UTC', + searchSource: {} as SerializedSearchSourceFields, + objectType: 'search', + title: 'test disallowed', + version: '7.14.0', + }, + reportingAPI.DATA_ANALYST_USERNAME, + reportingAPI.DATA_ANALYST_PASSWORD + ); + expect(res.status).to.eql(403); + }); + + it('does allow user with the role-based privilege', async () => { + const res = await reportingAPI.scheduleCsv( + { + browserTimezone: 'UTC', + title: 'allowed search', + objectType: 'search', + searchSource: { + version: true, + fields: [{ field: '*', include_unmapped: true }], + index: '5193f870-d861-11e9-a311-0fa548c5f953', + } as unknown as SerializedSearchSourceFields, + columns: [], + version: '7.13.0', + }, + reportingAPI.REPORTING_USER_USERNAME, + reportingAPI.REPORTING_USER_PASSWORD + ); + expect(res.status).to.eql(200); + + const soResult = await reportingAPI.getScheduledReports(res.body.job.id); + expect(soResult.status).to.eql(200); + expect(soResult.body._source.scheduled_report.title).to.eql('allowed search'); + scheduledReportIds.push(res.body.job.id); + + const taskResult = await reportingAPI.getTask(res.body.job.id); + expect(taskResult.status).to.eql(200); + testExpectedTask(res.body.job.id, 'csv_searchsource', taskResult.body); + scheduledReportTaskIds.push(res.body.job.id); + }); + }); + // This tests the same API as x-pack/test/api_integration/apis/security/privileges.ts, but it uses the non-deprecated config it('should register reporting privileges with the security privileges API', async () => { await supertest diff --git a/x-pack/test/reporting_api_integration/reporting_without_security/index.ts b/x-pack/test/reporting_api_integration/reporting_without_security/index.ts index cef965443962..4f4d4b116a5c 100644 --- a/x-pack/test/reporting_api_integration/reporting_without_security/index.ts +++ b/x-pack/test/reporting_api_integration/reporting_without_security/index.ts @@ -17,5 +17,6 @@ export default function ({ loadTestFile, getService }: FtrProviderContext) { }); loadTestFile(require.resolve('./csv/job_apis_csv')); + loadTestFile(require.resolve('./schedule')); }); } diff --git a/x-pack/test/reporting_api_integration/reporting_without_security/schedule.ts b/x-pack/test/reporting_api_integration/reporting_without_security/schedule.ts new file mode 100644 index 000000000000..9bd29ef4b3fd --- /dev/null +++ b/x-pack/test/reporting_api_integration/reporting_without_security/schedule.ts @@ -0,0 +1,47 @@ +/* + * 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 { FtrProviderContext } from '../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const reportingAPI = getService('reportingAPI'); + + describe('Scheduled Reports', () => { + before(async () => { + await reportingAPI.initLogs(); + }); + + after(async () => { + await reportingAPI.teardownLogs(); + }); + + afterEach(async () => { + await reportingAPI.deleteAllReports(); + }); + + it('should return error when scheduling reports', async () => { + const res = await reportingAPI.schedulePdf( + reportingAPI.REPORTING_USER_USERNAME, + reportingAPI.REPORTING_USER_PASSWORD, + { + browserTimezone: 'UTC', + title: 'test PDF allowed', + layout: { id: 'preserve_layout' }, + locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }], + objectType: 'dashboard', + version: '7.14.0', + } + ); + expect(res.status).to.eql(403); + expect(res.body.message).to.eql( + `Security and API keys must be enabled for scheduled reporting` + ); + }); + }); +} diff --git a/x-pack/test/reporting_api_integration/services/scenarios.ts b/x-pack/test/reporting_api_integration/services/scenarios.ts index 559a2fd5ba43..89cc22ec2865 100644 --- a/x-pack/test/reporting_api_integration/services/scenarios.ts +++ b/x-pack/test/reporting_api_integration/services/scenarios.ts @@ -15,6 +15,8 @@ import { REPORTING_DATA_STREAM_WILDCARD_WITH_LEGACY, } from '@kbn/reporting-server'; import rison from '@kbn/rison'; +import { ALERTING_CASES_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; +import { RruleSchedule } from '@kbn/task-manager-plugin/server'; import { FtrProviderContext } from '../ftr_provider_context'; function removeWhitespace(str: string) { @@ -39,6 +41,9 @@ export function createScenarios({ getService }: Pick { // Check task manager health for analyzing test failures. See https://github.com/elastic/kibana/issues/114946 @@ -126,6 +131,36 @@ export function createScenarios({ getService }: Pick { + await security.role.create(MANAGE_REPORTING_ROLE, { + metadata: {}, + elasticsearch: { + cluster: [], + indices: [ + { + names: ['ecommerce'], + privileges: ['read', 'view_index_metadata'], + allow_restricted_indices: false, + }, + ], + run_as: [], + }, + kibana: [ + { + base: [], + feature: { + manageReporting: ['all'], + dashboard: ['minimal_read', 'download_csv_report', 'generate_report'], + discover: ['minimal_read', 'generate_report'], + canvas: ['minimal_read', 'generate_report'], + visualize: ['minimal_read', 'generate_report'], + }, + spaces: ['*'], + }, + ], + }); + }; + const createDataAnalyst = async () => { await security.user.create('data_analyst', { password: 'data_analyst-password', @@ -134,6 +169,14 @@ export function createScenarios({ getService }: Pick { + await security.user.create(MANAGE_REPORTING_USER_USERNAME, { + password: MANAGE_REPORTING_USER_PASSWORD, + roles: [MANAGE_REPORTING_ROLE], + full_name: 'Manage Reporting User', + }); + }; + const createTestReportingUser = async () => { await security.user.create(REPORTING_USER_USERNAME, { password: REPORTING_USER_PASSWORD, @@ -156,6 +199,19 @@ export function createScenarios({ getService }: Pick { + const jobParams = rison.encode(job); + return await supertestWithoutAuth + .post(`/internal/reporting/schedule/printablePdfV2`) + .auth(username, password) + .set('kbn-xsrf', 'xxx') + .send({ jobParams, schedule }); + }; const generatePng = async ( username: string, password: string, @@ -170,6 +226,19 @@ export function createScenarios({ getService }: Pick { + const jobParams = rison.encode(job); + return await supertestWithoutAuth + .post(`/internal/reporting/schedule/pngV2`) + .auth(username, password) + .set('kbn-xsrf', 'xxx') + .send({ jobParams, schedule }); + }; const generateCsv = async ( job: JobParamsCSV, username = 'elastic', @@ -184,6 +253,46 @@ export function createScenarios({ getService }: Pick { + const jobParams = rison.encode(job); + + return await supertestWithoutAuth + .post(`/internal/reporting/schedule/csv_searchsource`) + .auth(username, password) + .set('kbn-xsrf', 'xxx') + .send({ jobParams, schedule }); + }; + + const listScheduledReports = async ( + username = 'elastic', + password = process.env.TEST_KIBANA_PASS || 'changeme' + ) => { + const res = await supertestWithoutAuth + .get(INTERNAL_ROUTES.SCHEDULED.LIST) + .auth(username, password) + .set('kbn-xsrf', 'xxx'); + + return res.body; + }; + + const disableScheduledReports = async ( + ids: string[], + username = 'elastic', + password = process.env.TEST_KIBANA_PASS || 'changeme' + ) => { + const { body } = await supertestWithoutAuth + .patch(INTERNAL_ROUTES.SCHEDULED.BULK_DISABLE) + .auth(username, password) + .set('kbn-xsrf', 'xxx') + .send({ ids }) + .expect(200); + return body; + }; const postJob = async ( apiPath: string, @@ -221,7 +330,6 @@ export function createScenarios({ getService }: Pick { + return await esSupertest.get( + `/${ALERTING_CASES_SAVED_OBJECT_INDEX}/_doc/scheduled_report:${id}` + ); + }; + + const deleteScheduledReports = async (ids: string[]) => { + return await Promise.all( + ids.map((id) => + esSupertest.delete(`/${ALERTING_CASES_SAVED_OBJECT_INDEX}/_doc/scheduled_report:${id}`) + ) + ); + }; + + const getTask = async (taskId: string) => { + return await esSupertest.get(`/.kibana_task_manager/_doc/task:${taskId}`); + }; + + const deleteTasks = async (ids: string[]) => { + return await Promise.all( + ids.map((id) => esSupertest.delete(`/.kibana_task_manager/_doc/task:${id}`)) + ); + }; + return { logTaskManagerHealth, initEcommerce, @@ -301,13 +433,21 @@ export function createScenarios({ getService }: Pick