diff --git a/packages/kbn-check-mappings-update-cli/current_fields.json b/packages/kbn-check-mappings-update-cli/current_fields.json index fa63eee1e8430..70f1f6e75ff80 100644 --- a/packages/kbn-check-mappings-update-cli/current_fields.json +++ b/packages/kbn-check-mappings-update-cli/current_fields.json @@ -918,6 +918,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 08f862ff517f7..9f09f9c64ac46 100644 --- a/packages/kbn-check-mappings-update-cli/current_mappings.json +++ b/packages/kbn-check-mappings-update-cli/current_mappings.json @@ -3045,6 +3045,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 47254ed205309..fa951d0fa52ee 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 @@ -122,6 +122,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/routes.ts b/src/platform/packages/private/kbn-reporting/common/routes.ts index 4fb56700cda28..92a6fa0cc5356 100644 --- a/src/platform/packages/private/kbn-reporting/common/routes.ts +++ b/src/platform/packages/private/kbn-reporting/common/routes.ts @@ -26,6 +26,7 @@ export const INTERNAL_ROUTES = { }, 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/x-pack/platform/plugins/private/canvas/server/feature.test.ts b/x-pack/platform/plugins/private/canvas/server/feature.test.ts index 7f71bafd41700..6fde4788a9c89 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 daa8a8fc4aa4f..4aa03b73425f4 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 a27970e2cec8d..1a3b40c96ca46 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 b082dd526e9ef..e5cf0c8a079e5 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'; @@ -55,8 +57,10 @@ import type { IReport, ReportingStore } from './lib/store'; import { ExecuteReportTask, ReportTaskParams } from './lib/tasks'; import type { ReportingPluginRouter } from './types'; import { EventTracker } from './usage'; +import { SCHEDULED_REPORT_SAVED_OBJECT_TYPE } from './saved_objects'; export interface ReportingInternalSetup { + actions: ActionsPluginSetupContract; basePath: Pick; docLinks: DocLinksServiceSetup; encryptedSavedObjects: EncryptedSavedObjectsPluginSetup; @@ -354,6 +358,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 +385,15 @@ export class ReportingCore { return dataViews; } + public async getSoClient(request: KibanaRequest) { + const { savedObjects } = await this.getPluginStartDeps(); + const savedObjectsClient = savedObjects.getScopedClient(request, { + excludedExtensions: [SECURITY_EXTENSION_ID], + includedHiddenTypes: [SCHEDULED_REPORT_SAVED_OBJECT_TYPE], + }); + return savedObjectsClient; + } + public async getDataService() { const startDeps = await this.getPluginStartDeps(); return startDeps.data; 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 2322794836989..df827abce22ae 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); diff --git a/x-pack/platform/plugins/private/reporting/server/plugin.ts b/x-pack/platform/plugins/private/reporting/server/plugin.ts index 12bfb3decb805..42d0889b08477 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/generate/request_handler.ts b/x-pack/platform/plugins/private/reporting/server/routes/common/generate/request_handler.ts deleted file mode 100644 index 2ba63a5d52655..0000000000000 --- a/x-pack/platform/plugins/private/reporting/server/routes/common/generate/request_handler.ts +++ /dev/null @@ -1,260 +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 { 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 payload = { - ...job, - headers, - title: job.title, - objectType: jobParams.objectType, - browserTimezone: jobParams.browserTimezone, - version: jobParams.version, - spaceId: reporting.getSpaceId(req, logger), - }; - - // 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, - 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 dab96944ea6e8..3f2aa4fd5e3b2 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 2ded83071d6a3..6f866a56ef4c5 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(` @@ -157,7 +160,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'); @@ -206,10 +212,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", } @@ -224,8 +234,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", } @@ -233,30 +247,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 0000000000000..98df5a62533e9 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/generate_request_handler.ts @@ -0,0 +1,130 @@ +/* + * 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 { 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); + + // 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: reporting.getSpaceId(req, logger), + }; + + // 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, + 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 0000000000000..185b6ec86c37a --- /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 0000000000000..d80e6e2e93b0d --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/lib/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 { transformRawScheduledReportToReport } from './transform_raw_scheduled_report'; 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 0000000000000..3006ff48bfad3 --- /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/request_handler.ts b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/request_handler.ts new file mode 100644 index 0000000000000..55bffafc82552 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/request_handler.ts @@ -0,0 +1,207 @@ +/* + * 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; + 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 0000000000000..418242ecb57b1 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/schedule_request_handler.test.ts @@ -0,0 +1,625 @@ +/* + * 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 { + 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 { createMockReportingCore } from '../../../test_helpers'; +import { ReportingRequestHandlerContext, ReportingSetup } from '../../../types'; +import { ScheduleRequestHandler } from './schedule_request_handler'; + +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; + + beforeEach(async () => { + reportingCore = await createMockReportingCore(createMockConfigSchema({})); + + 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); + + soClient = await reportingCore.getSoClient(fakeRawRequest as unknown as KibanaRequest); + soClient.create = jest.fn().mockImplementation(async (_, opts) => { + return { + id: 'foo', + attributes: opts, + type: 'scheduled-report', + }; + }); + + 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', 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(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, + }, + }); + }); + + 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(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'] } }, + }); + }); + }); + + 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(() => ({ + csv_searchsource: { + 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 reportingHealth.hasPermanentEncryptionKey = false', async () => { + jest.spyOn(reportingCore, 'getHealthInfo').mockResolvedValue({ + 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').mockResolvedValue({ + 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", + } + `); + }); + }); +}); 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 0000000000000..3ac2a26d285a9 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/schedule_request_handler.ts @@ -0,0 +1,193 @@ +/* + * 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 { 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 } from './lib'; + +// 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 +> { + 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 { exportTypeId, jobParams, schedule, notification } = params; + const { reporting, logger, req, user } = this.opts; + + const soClient = await reporting.getSoClient(req); + const { version, job, jobType } = await this.createJob(exportTypeId, jobParams); + + 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(), + createdBy: user ? user.username : false, + 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 + ); + logger.debug(`Successfully created scheduled report: ${report.id}`); + + // TODO - Schedule the report with Task Manager + + return transformRawScheduledReportToReport(report); + } + + public async handleRequest(params: RequestParams) { + const { exportTypeId, jobParams } = params; + const { reporting, 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`, + }); + } + + let report: ScheduledReportApiJSON | undefined; + try { + report = await this.enqueueJob(params); + return res.ok({ + headers: { 'content-type': 'application/json' }, + body: { + job: report, + }, + }); + } catch (err) { + return this.handleError(err, undefined, report?.jobtype); + } + } +} 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 f9fbd12802e26..31414587802f4 100644 --- a/x-pack/platform/plugins/private/reporting/server/routes/index.ts +++ b/x-pack/platform/plugins/private/reporting/server/routes/index.ts @@ -11,6 +11,7 @@ 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 { registerGenerationRoutesPublic } from './public/generate_from_jobparams'; import { registerJobInfoRoutesPublic } from './public/jobs'; @@ -20,6 +21,7 @@ export function registerRoutes(reporting: ReportingCore, logger: Logger) { registerHealthRoute(reporting, logger); registerDiagnosticRoutes(reporting, logger); registerGenerationRoutesInternal(reporting, logger); + registerScheduleRoutesInternal(reporting, logger); registerJobInfoRoutesInternal(reporting); 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 bd26c88bf6a0a..2d762ebf1cdb7 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 492d579727c51..bfb4c3a59312f 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/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 0000000000000..23f2229090234 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/routes/internal/schedule/integration_tests/scheduling_from_jobparams.test.ts @@ -0,0 +1,356 @@ +/* + * 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'; + +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 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' }), + }, + }, + }, + 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.getSoClient(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 0000000000000..0c501c4321898 --- /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 f547faa9cab52..34507cf79d56c 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 0000000000000..df34d38fc50d8 --- /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 0000000000000..285297b977eef --- /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 0000000000000..26520db4d22ab --- /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 0000000000000..4123d20974d6c --- /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/routes/common/generate/index.ts b/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/schemas/index.ts similarity index 76% rename from x-pack/platform/plugins/private/reporting/server/routes/common/generate/index.ts rename to x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/schemas/index.ts index a16ddf1204b8f..6df4417bb6cef 100644 --- a/x-pack/platform/plugins/private/reporting/server/routes/common/generate/index.ts +++ b/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/schemas/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { handleUnavailable, RequestHandler } from './request_handler'; +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 0000000000000..6f684f9d7cbd7 --- /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 0000000000000..f79b49d3eeed6 --- /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.oneOf([schema.string(), schema.boolean()]), + 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/test_helpers/create_mock_reportingplugin.ts b/x-pack/platform/plugins/private/reporting/server/test_helpers/create_mock_reportingplugin.ts index 36b4420f1670d..c24dabf83f369 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,7 +42,13 @@ 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() }, @@ -56,6 +64,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 +80,7 @@ export const createMockPluginStart = async ( return { analytics: coreSetupMock.analytics, esClient: elasticsearchServiceMock.createClusterClient(), - savedObjects: { getScopedClient: jest.fn() }, + savedObjects: { getScopedClient: 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 e4644380227b4..ceeed3e955c95 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'; @@ -24,15 +24,21 @@ import type { import type { SecurityPluginSetup } 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 { PluginSetupContract as ActionsPluginSetupContract } from '@kbn/actions-plugin/server'; import type { NotificationsPluginStart } from '@kbn/notifications-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 { + 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; @@ -92,6 +99,29 @@ 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 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 c7cfd10ec35e3..5ec0123d9749f 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,6 +53,7 @@ "@kbn/react-kibana-mount", "@kbn/core-security-common", "@kbn/core-http-server-utils", + "@kbn/core-saved-objects-server", "@kbn/notifications-plugin", ], "exclude": [ 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 b8b5910d83921..01a0046f28e8a 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 39e016301b0ef..7f3f815453cfb 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/task_manager/server/index.ts b/x-pack/platform/plugins/shared/task_manager/server/index.ts index 6678d4c08ca18..a20f3bf83b41f 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 d3e562c67b723..e030aa9c61e8a 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 9994426fb0831..75b200acc38b1 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 365cc506cb123..874f374355bbb 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 2e22f5be2b4a5..b9a59e3452267 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/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 0b5237a9051d6..5604ff058c34e 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 @@ -162,6 +162,161 @@ 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.getScheduledReportSO(res.body.job.id); + expect(soResult.status).to.eql(200); + expect(soResult.body._source.scheduled_report.title).to.eql('test PDF allowed'); + }); + }); + + 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.getScheduledReportSO(res.body.job.id); + expect(soResult.status).to.eql(200); + expect(soResult.body._source.scheduled_report.title).to.eql('test PDF allowed'); + }); + }); + + 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.getScheduledReportSO(res.body.job.id); + expect(soResult.status).to.eql(200); + expect(soResult.body._source.scheduled_report.title).to.eql('test PDF allowed'); + }); + }); + + 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.getScheduledReportSO(res.body.job.id); + expect(soResult.status).to.eql(200); + expect(soResult.body._source.scheduled_report.title).to.eql('allowed search'); + }); + }); + // 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/services/scenarios.ts b/x-pack/test/reporting_api_integration/services/scenarios.ts index 0800647d2abef..adcd3151aef20 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) { @@ -150,6 +152,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, job: JobParamsPNGV2) => { const jobParams = rison.encode(job); return await supertestWithoutAuth @@ -158,6 +173,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', @@ -171,6 +199,20 @@ 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 postJob = async ( apiPath: string, @@ -263,6 +305,12 @@ export function createScenarios({ getService }: Pick { + return await esSupertest.get( + `/${ALERTING_CASES_SAVED_OBJECT_INDEX}/_doc/scheduled_report:${id}` + ); + }; + return { logTaskManagerHealth, initEcommerce, @@ -281,6 +329,9 @@ export function createScenarios({ getService }: Pick