[Response Ops][Reporting] Scheduled Reports (#221028)

Resolves https://github.com/elastic/kibana/issues/216313

## Summary

This is a feature branch that contains the following commits. Each
individual linked PR contains a summary and verification instructions.

* Schedule API - https://github.com/elastic/kibana/pull/219771
* Scheduled report task runner -
https://github.com/elastic/kibana/pull/219770
* List and disable API - https://github.com/elastic/kibana/pull/220922
* Audit logging - https://github.com/elastic/kibana/pull/221846
* Send scheduled report emails -
https://github.com/elastic/kibana/pull/220539
* Commit to check license -
f5f9d9daed
* Update to list API response format -
https://github.com/elastic/kibana/pull/224262

---------

Co-authored-by: Ersin Erdal <ersin.erdal@elastic.co>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Ersin Erdal <92688503+ersin-erdal@users.noreply.github.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: Alexi Doak <109488926+doakalexi@users.noreply.github.com>
This commit is contained in:
Ying Mao 2025-06-19 09:20:18 -04:00 committed by GitHub
parent 900b1859ae
commit a409627765
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
93 changed files with 8397 additions and 679 deletions

View file

@ -958,6 +958,9 @@
"installCount",
"unInstallCount"
],
"scheduled_report": [
"createdBy"
],
"search": [
"description",
"title"

View file

@ -3119,6 +3119,14 @@
}
}
},
"scheduled_report": {
"dynamic": false,
"properties": {
"createdBy": {
"type": "keyword"
}
}
},
"search": {
"dynamic": false,
"properties": {

View file

@ -126,6 +126,7 @@ const previouslyRegisteredTypes = [
'query',
'rules-settings',
'sample-data-telemetry',
'scheduled_report',
'search',
'search-session',
'search-telemetry',

View file

@ -72,6 +72,13 @@ export class AuthenticationExpiredError extends ReportingError {
}
}
export class MissingAuthenticationError extends ReportingError {
static code = 'missing_authentication_header_error' as const;
public get code(): string {
return MissingAuthenticationError.code;
}
}
export class QueueTimeoutError extends ReportingError {
static code = 'queue_timeout_error' as const;
public get code(): string {

View file

@ -24,8 +24,13 @@ export const INTERNAL_ROUTES = {
DELETE_PREFIX: prefixInternalPath + '/jobs/delete', // docId is added to the final path
DOWNLOAD_PREFIX: prefixInternalPath + '/jobs/download', // docId is added to the final path
},
SCHEDULED: {
LIST: prefixInternalPath + '/scheduled/list',
BULK_DISABLE: prefixInternalPath + '/scheduled/bulk_disable',
},
HEALTH: prefixInternalPath + '/_health',
GENERATE_PREFIX: prefixInternalPath + '/generate', // exportTypeId is added to the final path
SCHEDULE_PREFIX: prefixInternalPath + '/schedule', // exportTypeId is added to the final path
};
const prefixPublicPath = '/api/reporting';

View file

@ -66,6 +66,7 @@ export interface BaseParams {
objectType: string;
title: string;
version: string; // to handle any state migrations
forceNow?: string;
layout?: LayoutParams; // png & pdf only
pagingStrategy?: CsvPagingStrategy; // csv only
}
@ -152,6 +153,7 @@ export interface ReportSource {
created_at: string; // timestamp in UTC
'@timestamp'?: string; // creation timestamp, only used for data streams compatibility
status: JOB_STATUS;
scheduled_report_id?: string;
/*
* `output` is only populated if the report job is completed or failed.

View file

@ -49,6 +49,16 @@ describe('check_license', () => {
it('should set management.jobTypes to undefined', () => {
expect(checkLicense(exportTypesRegistry, undefined).management.jobTypes).toEqual(undefined);
});
it('should set scheduledReports.showLinks to true', () => {
expect(checkLicense(exportTypesRegistry, undefined).scheduledReports.showLinks).toEqual(true);
});
it('should set scheduledReports.enableLinks to false', () => {
expect(checkLicense(exportTypesRegistry, undefined).scheduledReports.enableLinks).toEqual(
false
);
});
});
describe('license information is not available', () => {
@ -82,6 +92,16 @@ describe('check_license', () => {
it('should set management.jobTypes to undefined', () => {
expect(checkLicense(exportTypesRegistry, license).management.jobTypes).toEqual(undefined);
});
it('should set scheduledReports.showLinks to true', () => {
expect(checkLicense(exportTypesRegistry, license).scheduledReports.showLinks).toEqual(true);
});
it('should set scheduledReports.enableLinks to false', () => {
expect(checkLicense(exportTypesRegistry, license).scheduledReports.enableLinks).toEqual(
false
);
});
});
describe('license information is available', () => {
@ -121,6 +141,18 @@ describe('check_license', () => {
'printable_pdf'
);
});
it('should set scheduledReports.showLinks to true', () => {
expect(checkLicense(exportTypesRegistry, license).scheduledReports.showLinks).toEqual(
true
);
});
it('should set scheduledReports.enableLinks to true', () => {
expect(checkLicense(exportTypesRegistry, license).scheduledReports.enableLinks).toEqual(
true
);
});
});
describe('& license is expired', () => {
@ -147,6 +179,18 @@ describe('check_license', () => {
it('should set management.jobTypes to undefined', () => {
expect(checkLicense(exportTypesRegistry, license).management.jobTypes).toEqual(undefined);
});
it('should set scheduledReports.showLinks to true', () => {
expect(checkLicense(exportTypesRegistry, license).scheduledReports.showLinks).toEqual(
true
);
});
it('should set scheduledReports.enableLinks to false', () => {
expect(checkLicense(exportTypesRegistry, license).scheduledReports.enableLinks).toEqual(
false
);
});
});
});
@ -175,6 +219,12 @@ describe('check_license', () => {
expect(checkLicense(exportTypesRegistry, license).management.jobTypes).toEqual([]);
expect(checkLicense(exportTypesRegistry, license).management.jobTypes).toHaveLength(0);
});
it('should set scheduledReports.showLinks to false', () => {
expect(checkLicense(exportTypesRegistry, license).scheduledReports.showLinks).toEqual(
false
);
});
});
describe('& license is expired', () => {
@ -193,6 +243,12 @@ describe('check_license', () => {
it('should set management.jobTypes to undefined', () => {
expect(checkLicense(exportTypesRegistry, license).management.jobTypes).toEqual(undefined);
});
it('should set scheduledReports.showLinks to true', () => {
expect(checkLicense(exportTypesRegistry, license).scheduledReports.showLinks).toEqual(
true
);
});
});
});
});

View file

@ -7,7 +7,14 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { ILicense } from '@kbn/licensing-plugin/server';
import { ILicense, LicenseType } from '@kbn/licensing-plugin/server';
import {
LICENSE_TYPE_CLOUD_STANDARD,
LICENSE_TYPE_ENTERPRISE,
LICENSE_TYPE_GOLD,
LICENSE_TYPE_PLATINUM,
LICENSE_TYPE_TRIAL,
} from '@kbn/reporting-common';
import type { ExportType } from '.';
import { ExportTypesRegistry } from './export_types_registry';
@ -18,6 +25,14 @@ export interface LicenseCheckResult {
jobTypes?: string[];
}
const scheduledReportValidLicenses: LicenseType[] = [
LICENSE_TYPE_TRIAL,
LICENSE_TYPE_CLOUD_STANDARD,
LICENSE_TYPE_GOLD,
LICENSE_TYPE_PLATINUM,
LICENSE_TYPE_ENTERPRISE,
];
const messages = {
getUnavailable: () => {
return 'You cannot use Reporting because license information is not available at this time.';
@ -60,6 +75,42 @@ const makeManagementFeature = (exportTypes: ExportType[]) => {
};
};
const makeScheduledReportsFeature = () => {
return {
id: 'scheduledReports',
checkLicense: (license?: ILicense) => {
if (!license || !license.type) {
return {
showLinks: true,
enableLinks: false,
message: messages.getUnavailable(),
};
}
if (!license.isActive) {
return {
showLinks: true,
enableLinks: false,
message: messages.getExpired(license),
};
}
if (!scheduledReportValidLicenses.includes(license.type)) {
return {
showLinks: false,
enableLinks: false,
message: `Your ${license.type} license does not support Scheduled reports. Please upgrade your license.`,
};
}
return {
showLinks: true,
enableLinks: true,
};
},
};
};
const makeExportTypeFeature = (exportType: ExportType) => {
return {
id: exportType.id,
@ -104,6 +155,7 @@ export function checkLicense(
const reportingFeatures = [
...exportTypes.map(makeExportTypeFeature),
makeManagementFeature(exportTypes),
makeScheduledReportsFeature(),
];
return reportingFeatures.reduce((result, feature) => {

View file

@ -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 [

View file

@ -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'],
},

View file

@ -16,6 +16,7 @@
"reporting"
],
"requiredPlugins": [
"actions",
"data",
"discover",
"encryptedSavedObjects",

View file

@ -23,6 +23,8 @@ import type {
StatusServiceSetup,
UiSettingsServiceStart,
} from '@kbn/core/server';
import { SECURITY_EXTENSION_ID } from '@kbn/core-saved-objects-server';
import type { PluginSetupContract as ActionsPluginSetupContract } from '@kbn/actions-plugin/server';
import type { PluginStart as DataPluginStart } from '@kbn/data-plugin/server';
import type { DiscoverServerPluginStart } from '@kbn/discover-plugin/server';
import type { FeaturesPluginSetup } from '@kbn/features-plugin/server';
@ -35,7 +37,7 @@ import { PngExportType } from '@kbn/reporting-export-types-png';
import type { ReportingConfigType } from '@kbn/reporting-server';
import { ExportType } from '@kbn/reporting-server';
import { ScreenshottingStart } from '@kbn/screenshotting-plugin/server';
import type { SecurityPluginSetup } from '@kbn/security-plugin/server';
import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/server';
import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common';
import type { SpacesPluginSetup } from '@kbn/spaces-plugin/server';
import type {
@ -52,11 +54,20 @@ import type { ReportingSetup } from '.';
import { createConfig } from './config';
import { reportingEventLoggerFactory } from './lib/event_logger/logger';
import type { IReport, ReportingStore } from './lib/store';
import { ExecuteReportTask, ReportTaskParams } from './lib/tasks';
import {
RunSingleReportTask,
ReportTaskParams,
RunScheduledReportTask,
ScheduledReportTaskParamsWithoutSpaceId,
} from './lib/tasks';
import type { ReportingPluginRouter } from './types';
import { EventTracker } from './usage';
import { SCHEDULED_REPORT_SAVED_OBJECT_TYPE } from './saved_objects';
import { EmailNotificationService } from './services/notifications/email_notification_service';
import { API_PRIVILEGES } from './features';
export interface ReportingInternalSetup {
actions: ActionsPluginSetupContract;
basePath: Pick<IBasePath, 'set'>;
docLinks: DocLinksServiceSetup;
encryptedSavedObjects: EncryptedSavedObjectsPluginSetup;
@ -83,6 +94,7 @@ export interface ReportingInternalStart {
logger: Logger;
notifications: NotificationsPluginStart;
screenshotting?: ScreenshottingStart;
security?: SecurityPluginStart;
securityService: SecurityServiceStart;
taskManager: TaskManagerStartContract;
}
@ -96,7 +108,8 @@ export class ReportingCore {
private pluginStartDeps?: ReportingInternalStart;
private readonly pluginSetup$ = new Rx.ReplaySubject<boolean>(); // observe async background setupDeps each are done
private readonly pluginStart$ = new Rx.ReplaySubject<ReportingInternalStart>(); // observe async background startDeps
private executeTask: ExecuteReportTask;
private runSingleReportTask: RunSingleReportTask;
private runScheduledReportTask: RunScheduledReportTask;
private config: ReportingConfigType;
private executing: Set<string>;
private exportTypesRegistry = new ExportTypesRegistry();
@ -117,7 +130,16 @@ export class ReportingCore {
this.getExportTypes().forEach((et) => {
this.exportTypesRegistry.register(et);
});
this.executeTask = new ExecuteReportTask(this, config, this.logger);
this.runSingleReportTask = new RunSingleReportTask({
reporting: this,
config,
logger: this.logger,
});
this.runScheduledReportTask = new RunScheduledReportTask({
reporting: this,
config,
logger: this.logger,
});
this.getContract = () => ({
registerExportTypes: (id) => id,
@ -142,9 +164,10 @@ export class ReportingCore {
et.setup(setupDeps);
});
const { executeTask } = this;
const { runSingleReportTask, runScheduledReportTask } = this;
setupDeps.taskManager.registerTaskDefinitions({
[executeTask.TYPE]: executeTask.getTaskDefinition(),
[runSingleReportTask.TYPE]: runSingleReportTask.getTaskDefinition(),
[runScheduledReportTask.TYPE]: runScheduledReportTask.getTaskDefinition(),
});
}
@ -159,10 +182,17 @@ export class ReportingCore {
et.start({ ...startDeps });
});
const { taskManager } = startDeps;
const { executeTask } = this;
const { taskManager, notifications } = startDeps;
const emailNotificationService = new EmailNotificationService({
notifications,
});
const { runSingleReportTask, runScheduledReportTask } = this;
// enable this instance to generate reports
await Promise.all([executeTask.init(taskManager)]);
await Promise.all([
runSingleReportTask.init(taskManager),
runScheduledReportTask.init(taskManager, emailNotificationService),
]);
}
public pluginStop() {
@ -278,6 +308,18 @@ export class ReportingCore {
};
}
public async canManageReportingForSpace(req: KibanaRequest): Promise<boolean> {
const { security } = await this.getPluginStartDeps();
const spaceId = this.getSpaceId(req);
const result = await security?.authz
.checkPrivilegesWithRequest(req)
.atSpace(spaceId ?? DEFAULT_SPACE_ID, {
kibana: [security?.authz.actions.api.get(API_PRIVILEGES.MANAGE_SCHEDULED_REPORTING)],
});
return result?.hasAllRequested ?? false;
}
/*
* Gives synchronous access to the config
*/
@ -322,13 +364,25 @@ export class ReportingCore {
}
public async scheduleTask(request: KibanaRequest, report: ReportTaskParams) {
return await this.executeTask.scheduleTask(request, report);
return await this.runSingleReportTask.scheduleTask(request, report);
}
public async scheduleRecurringTask(
request: KibanaRequest,
report: ScheduledReportTaskParamsWithoutSpaceId
) {
return await this.runScheduledReportTask.scheduleTask(request, report);
}
public async getStore() {
return (await this.getPluginStartDeps()).store;
}
public async getAuditLogger(request: KibanaRequest) {
const startDeps = await this.getPluginStartDeps();
return startDeps.securityService.audit.asScoped(request);
}
public async getLicenseInfo() {
const { license$ } = (await this.getPluginStartDeps()).licensing;
const registry = this.getExportTypesRegistry();
@ -354,6 +408,13 @@ export class ReportingCore {
);
}
public validateNotificationEmails(emails: string[]): string | undefined {
const pluginSetupDeps = this.getPluginSetupDeps();
return pluginSetupDeps.actions
.getActionsConfigurationUtilities()
.validateEmailAddresses(emails);
}
/*
* Gives synchronous access to the setupDeps
*/
@ -374,6 +435,24 @@ export class ReportingCore {
return dataViews;
}
public async getScopedSoClient(request: KibanaRequest) {
const { savedObjects } = await this.getPluginStartDeps();
return savedObjects.getScopedClient(request, {
excludedExtensions: [SECURITY_EXTENSION_ID],
includedHiddenTypes: [SCHEDULED_REPORT_SAVED_OBJECT_TYPE],
});
}
public async getInternalSoClient() {
const { savedObjects } = await this.getPluginStartDeps();
return savedObjects.createInternalRepository([SCHEDULED_REPORT_SAVED_OBJECT_TYPE]);
}
public async getTaskManager() {
const { taskManager } = await this.getPluginStartDeps();
return taskManager;
}
public async getDataService() {
const startDeps = await this.getPluginStartDeps();
return startDeps.data;

View file

@ -9,6 +9,11 @@ import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server';
import { i18n } from '@kbn/i18n';
import type { FeaturesPluginSetup } from '@kbn/features-plugin/server';
import { KibanaFeatureScope } from '@kbn/features-plugin/common';
import { SCHEDULED_REPORT_SAVED_OBJECT_TYPE } from './saved_objects';
export const API_PRIVILEGES = {
MANAGE_SCHEDULED_REPORTING: 'manage_scheduled_reports',
};
interface FeatureRegistrationOpts {
features: FeaturesPluginSetup;
@ -37,4 +42,29 @@ export function registerFeatures({ isServerless, features }: FeatureRegistration
}
features.enableReportingUiCapabilities();
features.registerKibanaFeature({
id: 'manageReporting',
name: i18n.translate('xpack.reporting.features.manageScheduledReportsFeatureName', {
defaultMessage: 'Manage Scheduled Reports',
}),
description: i18n.translate(
'xpack.reporting.features.manageScheduledReportsFeatureDescription',
{
defaultMessage: 'View and manage scheduled reports for all users in this space.',
}
),
category: DEFAULT_APP_CATEGORIES.management,
scope: [KibanaFeatureScope.Spaces, KibanaFeatureScope.Security],
app: [],
privileges: {
all: {
api: [API_PRIVILEGES.MANAGE_SCHEDULED_REPORTING],
savedObject: { all: [SCHEDULED_REPORT_SAVED_OBJECT_TYPE], read: [] },
ui: ['show'],
},
// No read-only mode currently supported
read: { disabled: true, savedObject: { all: [], read: [] }, ui: [] },
},
});
}

View file

@ -7,6 +7,7 @@
export { Report } from './report';
export { SavedReport } from './saved_report';
export { ScheduledReport } from './scheduled_report';
export { ReportingStore } from './store';
export { IlmPolicyManager } from './ilm_policy_manager';

View file

@ -63,6 +63,61 @@ describe('Class Report', () => {
expect(report._id).toBeDefined();
});
it('constructs Report instance when scheduled_task_id is defined', () => {
const report = new Report({
_index: '.reporting-test-index-12345',
jobtype: 'test-report',
created_by: 'created_by_test_string',
max_attempts: 50,
payload: {
headers: 'payload_test_field',
objectType: 'testOt',
title: 'cool report',
version: '7.14.0',
browserTimezone: 'UTC',
},
meta: { objectType: 'test' },
timeout: 30000,
scheduled_report_id: 'foobar',
});
expect(report.toReportSource()).toMatchObject({
attempts: 0,
completed_at: undefined,
created_by: 'created_by_test_string',
jobtype: 'test-report',
max_attempts: 50,
meta: { objectType: 'test' },
payload: { headers: 'payload_test_field', objectType: 'testOt' },
started_at: undefined,
status: 'pending',
timeout: 30000,
scheduled_report_id: 'foobar',
});
expect(report.toReportTaskJSON()).toMatchObject({
attempts: 0,
created_by: 'created_by_test_string',
index: '.reporting-test-index-12345',
jobtype: 'test-report',
meta: { objectType: 'test' },
payload: { headers: 'payload_test_field', objectType: 'testOt' },
});
expect(report.toApiJSON()).toMatchObject({
attempts: 0,
created_by: 'created_by_test_string',
index: '.reporting-test-index-12345',
jobtype: 'test-report',
max_attempts: 50,
payload: { objectType: 'testOt' },
meta: { objectType: 'test' },
status: 'pending',
timeout: 30000,
scheduled_report_id: 'foobar',
});
expect(report._id).toBeDefined();
});
it('updateWithEsDoc method syncs fields to sync ES metadata', () => {
const report = new Report({
_index: '.reporting-test-index-12345',

View file

@ -41,6 +41,7 @@ export class Report implements Partial<ReportSource & ReportDocumentHead> {
public readonly status: ReportSource['status'];
public readonly attempts: ReportSource['attempts'];
public readonly scheduled_report_id: ReportSource['scheduled_report_id'];
// fields with undefined values exist in report jobs that have not been claimed
public readonly kibana_name: ReportSource['kibana_name'];
@ -99,6 +100,7 @@ export class Report implements Partial<ReportSource & ReportDocumentHead> {
this.status = opts.status || JOB_STATUS.PENDING;
this.output = opts.output || null;
this.error = opts.error;
this.scheduled_report_id = opts.scheduled_report_id;
this.queue_time_ms = fields?.queue_time_ms;
this.execution_time_ms = fields?.execution_time_ms;
@ -142,6 +144,7 @@ export class Report implements Partial<ReportSource & ReportDocumentHead> {
space_id: this.space_id,
output: this.output || null,
metrics: this.metrics,
scheduled_report_id: this.scheduled_report_id,
};
}
@ -191,6 +194,7 @@ export class Report implements Partial<ReportSource & ReportDocumentHead> {
payload: omit(this.payload, 'headers'),
output: omit(this.output, 'content'),
metrics: this.metrics,
scheduled_report_id: this.scheduled_report_id,
};
}
}

View file

@ -0,0 +1,178 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Frequency } from '@kbn/task-manager-plugin/server';
import { ScheduledReport } from '.';
const payload = {
headers: '',
title: 'Test Report',
browserTimezone: '',
objectType: 'test',
version: '8.0.0',
};
test('ScheduledReport should return correctly formatted outputs', () => {
const scheduledReport = new ScheduledReport({
runAt: new Date('2023-10-01T00:00:00Z'),
kibanaId: 'instance-uuid',
kibanaName: 'kibana',
queueTimeout: 120000,
scheduledReport: {
id: 'report-so-id-111',
attributes: {
createdAt: new Date().toISOString(),
createdBy: 'test-user',
enabled: true,
jobType: 'test1',
meta: { objectType: 'test' },
migrationVersion: '8.0.0',
payload: JSON.stringify(payload),
schedule: { rrule: { freq: Frequency.DAILY, interval: 2, tzid: 'UTC' } },
title: 'Test Report',
},
references: [],
type: 'scheduled-report',
},
});
expect(scheduledReport.toReportTaskJSON()).toEqual({
attempts: 1,
created_at: '2023-10-01T00:00:00.000Z',
created_by: 'test-user',
id: expect.any(String),
index: '.kibana-reporting',
jobtype: 'test1',
meta: {
objectType: 'test',
},
payload: {
browserTimezone: '',
forceNow: '2023-10-01T00:00:00.000Z',
headers: '',
objectType: 'test',
title: 'Test Report [2023-10-01T00:00:00.000Z]',
version: '8.0.0',
},
});
expect(scheduledReport.toReportSource()).toEqual({
attempts: 1,
max_attempts: 1,
created_at: '2023-10-01T00:00:00.000Z',
created_by: 'test-user',
jobtype: 'test1',
meta: {
objectType: 'test',
},
migration_version: '7.14.0',
kibana_id: 'instance-uuid',
kibana_name: 'kibana',
output: null,
payload: {
browserTimezone: '',
forceNow: '2023-10-01T00:00:00.000Z',
headers: '',
objectType: 'test',
title: 'Test Report [2023-10-01T00:00:00.000Z]',
version: '8.0.0',
},
scheduled_report_id: 'report-so-id-111',
status: 'processing',
started_at: expect.any(String),
process_expiration: expect.any(String),
timeout: 120000,
});
expect(scheduledReport.toApiJSON()).toEqual({
id: expect.any(String),
index: '.kibana-reporting',
kibana_id: 'instance-uuid',
kibana_name: 'kibana',
jobtype: 'test1',
created_at: '2023-10-01T00:00:00.000Z',
created_by: 'test-user',
meta: {
objectType: 'test',
},
timeout: 120000,
max_attempts: 1,
status: 'processing',
attempts: 1,
started_at: expect.any(String),
migration_version: '7.14.0',
output: {},
queue_time_ms: expect.any(Number),
payload: {
browserTimezone: '',
forceNow: '2023-10-01T00:00:00.000Z',
objectType: 'test',
title: 'Test Report [2023-10-01T00:00:00.000Z]',
version: '8.0.0',
},
scheduled_report_id: 'report-so-id-111',
});
});
test('ScheduledReport should throw an error if report payload is malformed', () => {
const createInstance = () => {
return new ScheduledReport({
runAt: new Date('2023-10-01T00:00:00Z'),
kibanaId: 'instance-uuid',
kibanaName: 'kibana',
queueTimeout: 120000,
scheduledReport: {
id: 'report-so-id-111',
attributes: {
createdAt: new Date().toISOString(),
createdBy: 'test-user',
enabled: true,
jobType: 'test1',
meta: { objectType: 'test' },
migrationVersion: '8.0.0',
payload: 'abc',
schedule: { rrule: { freq: Frequency.DAILY, interval: 2, tzid: 'UTC' } },
title: 'Test Report',
},
references: [],
type: 'scheduled-report',
},
});
};
expect(createInstance).toThrowErrorMatchingInlineSnapshot(
`"Unable to parse payload from scheduled_report saved object: SyntaxError: Unexpected token 'a', \\"abc\\" is not valid JSON"`
);
});
test('ScheduledReport should throw an error if scheduled_report saved object is missing ID', () => {
const createInstance = () => {
return new ScheduledReport({
runAt: new Date('2023-10-01T00:00:00Z'),
kibanaId: 'instance-uuid',
kibanaName: 'kibana',
queueTimeout: 120000,
// @ts-expect-error - missing id
scheduledReport: {
attributes: {
createdAt: new Date().toISOString(),
createdBy: 'test-user',
enabled: true,
jobType: 'test1',
meta: { objectType: 'test' },
migrationVersion: '8.0.0',
payload: JSON.stringify(payload),
schedule: { rrule: { freq: Frequency.DAILY, interval: 2, tzid: 'UTC' } },
title: 'Test Report',
},
references: [],
type: 'scheduled-report',
},
});
};
expect(createInstance).toThrowErrorMatchingInlineSnapshot(
`"Invalid scheduled_report saved object - no id"`
);
});

View file

@ -0,0 +1,69 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import moment from 'moment';
import { JOB_STATUS } from '@kbn/reporting-common';
import { SavedObject } from '@kbn/core/server';
import { BasePayload } from '@kbn/reporting-common/types';
import { Report } from './report';
import { ScheduledReportType } from '../../types';
interface ConstructorOpts {
runAt: Date;
kibanaId: string;
kibanaName: string;
queueTimeout: number;
scheduledReport: SavedObject<ScheduledReportType>;
}
export class ScheduledReport extends Report {
/*
* Create a report from a scheduled_report saved object
*/
constructor(opts: ConstructorOpts) {
const { kibanaId, kibanaName, runAt, scheduledReport, queueTimeout } = opts;
const now = moment.utc();
const startTime = now.toISOString();
const expirationTime = now.add(queueTimeout).toISOString();
let payload: BasePayload;
try {
payload = JSON.parse(scheduledReport.attributes.payload);
} catch (e) {
throw new Error(`Unable to parse payload from scheduled_report saved object: ${e}`);
}
payload.forceNow = runAt.toISOString();
payload.title = `${scheduledReport.attributes.title} [${runAt.toISOString()}]`;
if (!scheduledReport.id) {
throw new Error(`Invalid scheduled_report saved object - no id`);
}
super(
{
migration_version: scheduledReport.attributes.migrationVersion,
jobtype: scheduledReport.attributes.jobType,
created_at: runAt.toISOString(),
created_by: scheduledReport.attributes.createdBy as string | false,
payload,
meta: scheduledReport.attributes.meta,
status: JOB_STATUS.PROCESSING,
attempts: 1,
process_expiration: expirationTime,
kibana_id: kibanaId,
kibana_name: kibanaName,
max_attempts: 1,
started_at: startTime,
timeout: queueTimeout,
scheduled_report_id: scheduledReport.id,
},
{ queue_time_ms: [now.diff(moment.utc(runAt), 'milliseconds')] }
);
}
}

View file

@ -60,6 +60,53 @@ describe('ReportingStore', () => {
});
});
it('uses report status if set', async () => {
const store = new ReportingStore(mockCore, mockLogger);
const mockReport = new Report({
_index: '.reporting-mock',
attempts: 0,
created_by: 'username1',
jobtype: 'unknowntype',
status: 'processing',
payload: {},
meta: {},
} as any);
await expect(store.addReport(mockReport)).resolves.toMatchObject({
_primary_term: undefined,
_seq_no: undefined,
attempts: 0,
completed_at: undefined,
created_by: 'username1',
jobtype: 'unknowntype',
payload: {},
meta: {},
status: 'processing',
});
});
it('defaults to pending status if not set', async () => {
const store = new ReportingStore(mockCore, mockLogger);
const mockReport = new Report({
_index: '.reporting-mock',
attempts: 0,
created_by: 'username1',
jobtype: 'unknowntype',
payload: {},
meta: {},
} as any);
await expect(store.addReport(mockReport)).resolves.toMatchObject({
_primary_term: undefined,
_seq_no: undefined,
attempts: 0,
completed_at: undefined,
created_by: 'username1',
jobtype: 'unknowntype',
payload: {},
meta: {},
status: 'pending',
});
});
it('throws if options has invalid indexInterval', async () => {
const reportingConfig = {
index: '.reporting-test',
@ -181,6 +228,7 @@ describe('ReportingStore', () => {
},
"process_expiration": undefined,
"queue_time_ms": undefined,
"scheduled_report_id": undefined,
"space_id": undefined,
"started_at": undefined,
"status": "pending",
@ -352,6 +400,48 @@ describe('ReportingStore', () => {
`);
});
it('setReportWarning sets the status of a saved report to warning', async () => {
const store = new ReportingStore(mockCore, mockLogger);
const report = new SavedReport({
_id: 'id-of-processing',
_index: '.reporting-test-index-12345',
_seq_no: 42,
_primary_term: 10002,
jobtype: 'test-report',
created_by: 'created_by_test_string',
max_attempts: 50,
payload: {
title: 'test report',
headers: 'rp_test_headers',
objectType: 'testOt',
browserTimezone: 'ABC',
version: '7.14.0',
},
timeout: 30000,
});
await store.setReportWarning(report, {
output: { warnings: ['warning1'] },
warning: 'warning2',
} as any);
const [[updateCall]] = mockEsClient.update.mock.calls;
const response = (updateCall as estypes.UpdateRequest)?.doc as Report;
expect(response.migration_version).toBe(`7.14.0`);
expect(response.status).toBe(`completed_with_warnings`);
expect(response.output).toMatchInlineSnapshot(`
Object {
"warnings": Array [
"warning1",
"warning2",
],
}
`);
expect(updateCall.if_seq_no).toBe(42);
expect(updateCall.if_primary_term).toBe(10002);
});
describe('start', () => {
class TestReportingStore extends ReportingStore {
constructor(...args: ConstructorParameters<typeof ReportingStore>) {

View file

@ -53,6 +53,11 @@ export type ReportCompletedFields = Required<{
output: Omit<ReportOutput, 'content'> | null;
}>;
export interface ReportWarningFields {
output: Omit<ReportOutput, 'content'>;
warning: string;
}
/*
* When searching for long-pending reports, we get a subset of fields
*/
@ -145,8 +150,8 @@ export class ReportingStore {
...report.toReportSource(),
...sourceDoc({
process_expiration: new Date(0).toISOString(),
attempts: 0,
status: JOB_STATUS.PENDING,
attempts: report.attempts || 0,
status: report.status || JOB_STATUS.PENDING,
}),
},
};
@ -337,4 +342,31 @@ export class ReportingStore {
return body;
}
public async setReportWarning(
report: SavedReport,
warningInfo: ReportWarningFields
): Promise<UpdateResponse<ReportDocument>> {
const { output, warning } = warningInfo;
const warnings: string[] = output.warnings ?? [];
warnings.push(warning);
const doc = sourceDoc({
output: {
...output,
warnings,
},
status: JOB_STATUS.WARNINGS,
} as ReportSource);
let body: UpdateResponse<ReportDocument>;
try {
const client = await this.getClient();
body = await client.update<unknown, unknown, ReportDocument>(esDocForUpdate(report, doc));
} catch (err) {
this.logError(`Error in updating status to warning! Report: ${jobDebugMessage(report)}`, err, report); // prettier-ignore
throw err;
}
return body;
}
}

View file

@ -5,14 +5,16 @@
* 2.0.
*/
import { TaskRunCreatorFunction } from '@kbn/task-manager-plugin/server';
import { RruleSchedule, TaskRegisterDefinition } from '@kbn/task-manager-plugin/server';
import { BasePayload, ReportSource } from '@kbn/reporting-common/types';
export const REPORTING_EXECUTE_TYPE = 'report:execute';
export const SCHEDULED_REPORTING_EXECUTE_TYPE = 'report:execute-scheduled';
export const TIME_BETWEEN_ATTEMPTS = 10 * 1000; // 10 seconds
export { ExecuteReportTask } from './execute_report';
export { RunSingleReportTask } from './run_single_report';
export { RunScheduledReportTask } from './run_scheduled_report';
export interface ReportTaskParams<JobPayloadType = BasePayload> {
id: string;
@ -25,18 +27,21 @@ export interface ReportTaskParams<JobPayloadType = BasePayload> {
meta: ReportSource['meta'];
}
export interface ScheduledReportTaskParams {
id: string;
jobtype: ReportSource['jobtype'];
spaceId: string;
schedule: RruleSchedule;
}
export type ScheduledReportTaskParamsWithoutSpaceId = Omit<ScheduledReportTaskParams, 'spaceId'>;
export enum ReportingTaskStatus {
UNINITIALIZED = 'uninitialized',
INITIALIZED = 'initialized',
}
export interface ReportingTask {
getTaskDefinition: () => {
type: string;
title: string;
createTaskRunner: TaskRunCreatorFunction;
maxAttempts: number;
timeout: string;
};
getTaskDefinition: () => TaskRegisterDefinition;
getStatus: () => ReportingTaskStatus;
}

View file

@ -11,11 +11,11 @@ import { timeout } from 'rxjs';
import { Writable } from 'stream';
import type { FakeRawRequest, Headers } from '@kbn/core-http-server';
import { UpdateResponse } from '@elastic/elasticsearch/lib/api/types';
import type { KibanaRequest, Logger } from '@kbn/core/server';
import type { KibanaRequest, Logger, SavedObject } from '@kbn/core/server';
import {
CancellationToken,
KibanaShuttingDownError,
QueueTimeoutError,
MissingAuthenticationError,
ReportingError,
durationToNumber,
numberToDuration,
@ -28,34 +28,28 @@ import type {
TaskRunResult,
} from '@kbn/reporting-common/types';
import { decryptJobHeaders, type ReportingConfigType } from '@kbn/reporting-server';
import type {
RunContext,
TaskManagerStartContract,
TaskRunCreatorFunction,
import {
throwRetryableError,
type ConcreteTaskInstance,
type RunContext,
type TaskManagerStartContract,
type TaskRegisterDefinition,
type TaskRunCreatorFunction,
} from '@kbn/task-manager-plugin/server';
import { throwRetryableError } from '@kbn/task-manager-plugin/server';
import { ExportTypesRegistry } from '@kbn/reporting-server/export_types_registry';
import { kibanaRequestFactory } from '@kbn/core-http-server-utils';
import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common';
import {
REPORTING_EXECUTE_TYPE,
ReportTaskParams,
ReportingTask,
ReportingTaskStatus,
TIME_BETWEEN_ATTEMPTS,
} from '.';
import { getContentStream, finishedWithNoPendingCallbacks } from '../content_stream';
import { mapToReportingError } from '../../../common/errors/map_to_reporting_error';
import { ReportTaskParams, ReportingTask, ReportingTaskStatus, TIME_BETWEEN_ATTEMPTS } from '.';
import type { ReportingCore } from '../..';
import {
isExecutionError,
mapToReportingError,
} from '../../../common/errors/map_to_reporting_error';
import { EventTracker } from '../../usage';
import type { ReportingStore } from '../store';
import { Report, SavedReport } from '../store';
import type { ReportFailedFields, ReportProcessingFields } from '../store/store';
import type { ReportFailedFields, ReportWarningFields } from '../store/store';
import { errorLogger } from './error_logger';
import { finishedWithNoPendingCallbacks, getContentStream } from '../content_stream';
import { EmailNotificationService } from '../../services/notifications/email_notification_service';
import { ScheduledReportType } from '../../types';
type CompletedReportOutput = Omit<ReportOutput, 'content'>;
@ -72,12 +66,6 @@ interface GetHeadersOpts {
requestFromTask?: KibanaRequest;
spaceId: string | undefined;
}
interface ReportingExecuteTaskInstance {
state: object;
taskType: string;
params: ReportTaskParams;
runAt?: Date;
}
function isOutput(output: CompletedReportOutput | Error): output is CompletedReportOutput {
return (output as CompletedReportOutput).size != null;
@ -95,63 +83,100 @@ function parseError(error: unknown): ExecutionError | unknown {
return error;
}
export class ExecuteReportTask implements ReportingTask {
public TYPE = REPORTING_EXECUTE_TYPE;
export interface ConstructorOpts {
config: ReportingConfigType;
logger: Logger;
reporting: ReportingCore;
}
private logger: Logger;
private taskManagerStart?: TaskManagerStartContract;
private kibanaId?: string;
private kibanaName?: string;
private exportTypesRegistry: ExportTypesRegistry;
private store?: ReportingStore;
private eventTracker?: EventTracker;
export interface PrepareJobResults {
isLastAttempt: boolean;
jobId: string;
report?: SavedReport;
task?: ReportTaskParams;
scheduledReport?: SavedObject<ScheduledReportType>;
}
constructor(
private reporting: ReportingCore,
private config: ReportingConfigType,
logger: Logger
) {
this.logger = logger.get('runTask');
this.exportTypesRegistry = this.reporting.getExportTypesRegistry();
type ReportTaskParamsType = Record<string, any>;
export abstract class RunReportTask<TaskParams extends ReportTaskParamsType>
implements ReportingTask
{
protected readonly logger: Logger;
protected readonly queueTimeout: number;
protected taskManagerStart?: TaskManagerStartContract;
protected kibanaId?: string;
protected kibanaName?: string;
protected exportTypesRegistry: ExportTypesRegistry;
protected eventTracker?: EventTracker;
protected emailNotificationService?: EmailNotificationService;
constructor(protected readonly opts: ConstructorOpts) {
this.logger = opts.logger.get('runTask');
this.exportTypesRegistry = opts.reporting.getExportTypesRegistry();
this.queueTimeout = durationToNumber(opts.config.queue.timeout);
}
/*
* To be called from plugin start
*/
public async init(taskManager: TaskManagerStartContract) {
// Abstract methods
public abstract get TYPE(): string;
public abstract getTaskDefinition(): TaskRegisterDefinition;
public abstract scheduleTask(
request: KibanaRequest,
params: TaskParams
): Promise<ConcreteTaskInstance>;
protected abstract prepareJob(taskInstance: ConcreteTaskInstance): Promise<PrepareJobResults>;
protected abstract getMaxAttempts(): number | undefined;
protected abstract notify(
report: SavedReport,
taskInstance: ConcreteTaskInstance,
output: TaskRunResult,
byteSize: number,
scheduledReport?: SavedObject<ScheduledReportType>,
spaceId?: string
): Promise<void>;
// Public methods
public async init(
taskManager: TaskManagerStartContract,
emailNotificationService?: EmailNotificationService
) {
this.taskManagerStart = taskManager;
const { reporting } = this;
const { uuid, name } = reporting.getServerInfo();
const { uuid, name } = this.opts.reporting.getServerInfo();
this.kibanaId = uuid;
this.kibanaName = name;
this.emailNotificationService = emailNotificationService;
}
/*
* Async get the ReportingStore: it is only available after PluginStart
*/
private async getStore(): Promise<ReportingStore> {
if (this.store) {
return this.store;
public getStatus() {
if (this.taskManagerStart) {
return ReportingTaskStatus.INITIALIZED;
}
const { store } = await this.reporting.getPluginStartDeps();
this.store = store;
return store;
return ReportingTaskStatus.UNINITIALIZED;
}
private getTaskManagerStart() {
// Protected methods
protected getTaskManagerStart() {
if (!this.taskManagerStart) {
throw new Error('Reporting task runner has not been initialized!');
}
return this.taskManagerStart;
}
private getEventTracker(report: Report) {
protected getEventTracker(report: Report) {
if (this.eventTracker) {
return this.eventTracker;
}
const eventTracker = this.reporting.getEventTracker(
const eventTracker = this.opts.reporting.getEventTracker(
report._id,
report.jobtype,
report.payload.objectType
@ -160,91 +185,26 @@ export class ExecuteReportTask implements ReportingTask {
return this.eventTracker;
}
private getJobContentEncoding(jobType: string) {
protected getJobContentEncoding(jobType: string) {
const exportType = this.exportTypesRegistry.getByJobType(jobType);
return exportType.jobContentEncoding;
}
private async _claimJob(task: ReportTaskParams): Promise<SavedReport> {
if (this.kibanaId == null) {
throw new Error(`Kibana instance ID is undefined!`);
}
if (this.kibanaName == null) {
throw new Error(`Kibana instance name is undefined!`);
}
const store = await this.getStore();
const report = await store.findReportFromTask(task); // receives seq_no and primary_term
const logger = this.logger.get(report._id);
if (report.status === 'completed') {
throw new Error(`Can not claim the report job: it is already completed!`);
}
const m = moment();
// check if job has exceeded the configured maxAttempts
const maxAttempts = this.getMaxAttempts();
if (report.attempts >= maxAttempts) {
let err: ReportingError;
if (report.error && isExecutionError(report.error)) {
// We have an error stored from a previous attempts, so we'll use that
// error to fail the job and return it to the user.
const { error } = report;
err = mapToReportingError(error);
err.stack = error.stack;
} else {
if (report.error && report.error instanceof Error) {
errorLogger(logger, 'Error executing report', report.error);
}
err = new QueueTimeoutError(
`Max attempts reached (${maxAttempts}). Queue timeout reached.`
);
}
await this._failJob(report, err);
throw err;
}
const queueTimeout = durationToNumber(this.config.queue.timeout);
const startTime = m.toISOString();
const expirationTime = m.add(queueTimeout).toISOString();
const doc: ReportProcessingFields = {
kibana_id: this.kibanaId,
kibana_name: this.kibanaName,
attempts: report.attempts + 1,
max_attempts: maxAttempts,
started_at: startTime,
timeout: queueTimeout,
process_expiration: expirationTime,
};
const claimedReport = new SavedReport({
...report,
...doc,
});
logger.info(
`Claiming ${claimedReport.jobtype} ${report._id} ` +
`[_index: ${report._index}] ` +
`[_seq_no: ${report._seq_no}] ` +
`[_primary_term: ${report._primary_term}] ` +
`[attempts: ${report.attempts}] ` +
`[process_expiration: ${expirationTime}]`
);
// event tracking of claimed job
const eventTracker = this.getEventTracker(report);
const timeSinceCreation = Date.now() - new Date(report.created_at).valueOf();
eventTracker?.claimJob({ timeSinceCreation });
const resp = await store.setReportClaimed(claimedReport, doc);
claimedReport._seq_no = resp._seq_no!;
claimedReport._primary_term = resp._primary_term!;
return claimedReport;
protected getJobContentExtension(jobType: string) {
const exportType = this.exportTypesRegistry.getByJobType(jobType);
return exportType.jobContentExtension;
}
private async _failJob(
protected getMaxConcurrency() {
return this.opts.config.queue.pollEnabled ? 1 : 0;
}
protected getQueueTimeout() {
// round up from ms to the nearest second
return Math.ceil(numberToDuration(this.opts.config.queue.timeout).asSeconds()) + 's';
}
protected async failJob(
report: SavedReport,
error?: ReportingError
): Promise<UpdateResponse<ReportDocument>> {
@ -255,13 +215,13 @@ export class ExecuteReportTask implements ReportingTask {
let docOutput;
if (error) {
errorLogger(logger, message, error);
docOutput = this._formatOutput(error);
docOutput = this.formatOutput(error);
} else {
errorLogger(logger, message);
}
// update the report in the store
const store = await this.getStore();
const store = await this.opts.reporting.getStore();
const completedTime = moment();
const doc: ReportFailedFields = {
completed_at: completedTime.toISOString(),
@ -280,7 +240,7 @@ export class ExecuteReportTask implements ReportingTask {
return await store.setReportFailed(report, doc);
}
private async _saveExecutionError(
protected async saveExecutionError(
report: SavedReport,
failedToExecuteErr: any
): Promise<UpdateResponse<ReportDocument>> {
@ -291,7 +251,7 @@ export class ExecuteReportTask implements ReportingTask {
errorLogger(logger, message, failedToExecuteErr);
// update the report in the store
const store = await this.getStore();
const store = await this.opts.reporting.getStore();
const doc: ReportFailedFields = {
output: null,
error: errorParsed,
@ -300,7 +260,25 @@ export class ExecuteReportTask implements ReportingTask {
return await store.setReportError(report, doc);
}
private _formatOutput(output: CompletedReportOutput | ReportingError): ReportOutput {
protected async saveExecutionWarning(
report: SavedReport,
output: CompletedReportOutput,
message: string
): Promise<UpdateResponse<ReportDocument>> {
const logger = this.logger.get(report._id);
logger.warn(message);
// update the report in the store
const store = await this.opts.reporting.getStore();
const doc: ReportWarningFields = {
output,
warning: message,
};
return await store.setReportWarning(report, doc);
}
protected formatOutput(output: CompletedReportOutput | ReportingError): ReportOutput {
const docOutput = {} as ReportOutput;
const unknownMime = null;
@ -324,7 +302,7 @@ export class ExecuteReportTask implements ReportingTask {
return docOutput;
}
private async _getRequestToUse({
protected async getRequestToUse({
requestFromTask,
spaceId,
encryptedHeaders,
@ -339,17 +317,17 @@ export class ExecuteReportTask implements ReportingTask {
}
let decryptedHeaders;
if (this.config.encryptionKey && encryptedHeaders) {
if (this.opts.config.encryptionKey && encryptedHeaders) {
// get decrypted headers
decryptedHeaders = await decryptJobHeaders(
this.config.encryptionKey,
this.opts.config.encryptionKey,
encryptedHeaders,
this.logger
);
}
if (!decryptedHeaders && !apiKeyAuthHeaders) {
throw new Error('No headers found to execute report');
throw new MissingAuthenticationError();
}
let headersToUse: Headers = {};
@ -367,10 +345,10 @@ export class ExecuteReportTask implements ReportingTask {
headersToUse = decryptedHeaders || {};
}
return this._getFakeRequest(headersToUse, spaceId, this.logger);
return this.getFakeRequest(headersToUse, spaceId, this.logger);
}
private _getFakeRequest(
protected getFakeRequest(
headers: Headers,
spaceId: string | undefined,
logger = this.logger
@ -381,7 +359,7 @@ export class ExecuteReportTask implements ReportingTask {
};
const fakeRequest = kibanaRequestFactory(rawRequest);
const setupDeps = this.reporting.getPluginSetupDeps();
const setupDeps = this.opts.reporting.getPluginSetupDeps();
const spacesService = setupDeps.spaces?.spacesService;
if (spacesService) {
if (spaceId && spaceId !== DEFAULT_SPACE_ID) {
@ -392,7 +370,7 @@ export class ExecuteReportTask implements ReportingTask {
return fakeRequest;
}
private async _performJob({
protected async performJob({
task,
fakeRequest,
taskInstanceFields,
@ -405,8 +383,7 @@ export class ExecuteReportTask implements ReportingTask {
}
// run the report
// if workerFn doesn't finish before timeout, call the cancellationToken and throw an error
const queueTimeout = durationToNumber(this.config.queue.timeout);
const request = await this._getRequestToUse({
const request = await this.getRequestToUse({
requestFromTask: fakeRequest,
spaceId: task.payload.spaceId,
encryptedHeaders: task.payload.headers,
@ -422,11 +399,11 @@ export class ExecuteReportTask implements ReportingTask {
cancellationToken,
stream,
})
).pipe(timeout(queueTimeout)) // throw an error if a value is not emitted before timeout
).pipe(timeout(this.queueTimeout)) // throw an error if a value is not emitted before timeout
);
}
private async _completeJob(
protected async completeJob(
report: SavedReport,
output: CompletedReportOutput
): Promise<SavedReport> {
@ -436,8 +413,8 @@ export class ExecuteReportTask implements ReportingTask {
logger.debug(`Saving ${report.jobtype} to ${docId}.`);
const completedTime = moment();
const docOutput = this._formatOutput(output);
const store = await this.getStore();
const docOutput = this.formatOutput(output);
const store = await this.opts.reporting.getStore();
const doc = {
completed_at: completedTime.toISOString(),
metrics: output.metrics,
@ -477,25 +454,20 @@ export class ExecuteReportTask implements ReportingTask {
}
// Generic is used to let TS infer the return type at call site.
private async throwIfKibanaShutsDown<T>(): Promise<T> {
await Rx.firstValueFrom(this.reporting.getKibanaShutdown$());
protected async throwIfKibanaShutsDown<T>(): Promise<T> {
await Rx.firstValueFrom(this.opts.reporting.getKibanaShutdown$());
throw new KibanaShuttingDownError();
}
/*
* Provides a TaskRunner for Task Manager
*/
private getTaskRunner(): TaskRunCreatorFunction {
protected getTaskRunner(): TaskRunCreatorFunction {
// Keep a separate local stack for each task run
return ({ taskInstance, fakeRequest }: RunContext) => {
let jobId: string;
const cancellationToken = new CancellationToken();
const {
attempts: taskAttempts,
params: reportTaskParams,
retryAt: taskRetryAt,
startedAt: taskStartedAt,
} = taskInstance;
const { retryAt: taskRetryAt, startedAt: taskStartedAt } = taskInstance;
return {
/*
@ -506,31 +478,30 @@ export class ExecuteReportTask implements ReportingTask {
* If any error happens, additional retry attempts may be picked up by a separate instance
*/
run: async () => {
let report: SavedReport | undefined;
const isLastAttempt = taskAttempts >= this.getMaxAttempts();
// find the job in the store and set status to processing
const task = reportTaskParams as ReportTaskParams;
jobId = task?.id;
try {
if (!jobId) {
throw new Error('Invalid report data provided in scheduled task!');
}
if (!isLastAttempt) {
this.reporting.trackReport(jobId);
}
// Update job status to claimed
report = await this._claimJob(task);
} catch (failedToClaim) {
// error claiming report - log the error
// could be version conflict, or too many attempts or no longer connected to ES
errorLogger(this.logger, `Error in claiming ${jobId}`, failedToClaim);
if (this.kibanaId == null) {
throw new Error(`Kibana instance ID is undefined!`);
}
if (this.kibanaName == null) {
throw new Error(`Kibana instance name is undefined!`);
}
if (!report) {
this.reporting.untrackReport(jobId);
let report: SavedReport | undefined;
const {
isLastAttempt,
jobId: jId,
report: preparedReport,
task,
scheduledReport,
} = await this.prepareJob(taskInstance);
jobId = jId;
report = preparedReport;
if (!isLastAttempt) {
this.opts.reporting.trackReport(jobId);
}
if (!report || !task) {
this.opts.reporting.untrackReport(jobId);
if (isLastAttempt) {
errorLogger(this.logger, `Job ${jobId} failed too many times. Exiting...`);
@ -545,22 +516,27 @@ export class ExecuteReportTask implements ReportingTask {
}
const { jobtype: jobType, attempts } = report;
const maxAttempts = this.getMaxAttempts();
const logger = this.logger.get(jobId);
logger.debug(
`Starting ${jobType} report ${jobId}: attempt ${attempts} of ${maxAttempts}.`
);
logger.debug(`Reports running: ${this.reporting.countConcurrentReports()}.`);
const maxAttempts = this.getMaxAttempts();
if (maxAttempts) {
logger.debug(
`Starting ${jobType} report ${jobId}: attempt ${attempts} of ${maxAttempts}.`
);
} else {
logger.debug(`Starting ${jobType} report ${jobId}.`);
}
const eventLog = this.reporting.getEventLogger(
logger.debug(`Reports running: ${this.opts.reporting.countConcurrentReports()}.`);
const eventLog = this.opts.reporting.getEventLogger(
new Report({ ...task, _id: task.id, _index: task.index })
);
try {
const jobContentEncoding = this.getJobContentEncoding(jobType);
const stream = await getContentStream(
this.reporting,
this.opts.reporting,
{
id: report._id,
index: report._index,
@ -574,7 +550,7 @@ export class ExecuteReportTask implements ReportingTask {
eventLog.logExecutionStart();
const output = await Promise.race<TaskRunResult>([
this._performJob({
this.performJob({
task,
fakeRequest,
taskInstanceFields: { retryAt: taskRetryAt, startedAt: taskStartedAt },
@ -593,18 +569,28 @@ export class ExecuteReportTask implements ReportingTask {
report._seq_no = stream.getSeqNo()!;
report._primary_term = stream.getPrimaryTerm()!;
const byteSize = stream.bytesWritten;
eventLog.logExecutionComplete({
...(output.metrics ?? {}),
byteSize: stream.bytesWritten,
byteSize,
});
if (output) {
logger.debug(`Job output size: ${stream.bytesWritten} bytes.`);
logger.debug(`Job output size: ${byteSize} bytes.`);
// Update the job status to "completed"
report = await this._completeJob(report, {
report = await this.completeJob(report, {
...output,
size: stream.bytesWritten,
size: byteSize,
});
await this.notify(
report,
taskInstance,
output,
byteSize,
scheduledReport,
task.payload.spaceId
);
}
// untrack the report for concurrency awareness
@ -612,11 +598,9 @@ export class ExecuteReportTask implements ReportingTask {
} catch (failedToExecuteErr) {
eventLog.logError(failedToExecuteErr);
await this._saveExecutionError(report, failedToExecuteErr).catch(
(failedToSaveError) => {
errorLogger(logger, `Error in saving execution error ${jobId}`, failedToSaveError);
}
);
await this.saveExecutionError(report, failedToExecuteErr).catch((failedToSaveError) => {
errorLogger(logger, `Error in saving execution error ${jobId}`, failedToSaveError);
});
cancellationToken.cancel();
@ -624,8 +608,8 @@ export class ExecuteReportTask implements ReportingTask {
throwRetryableError(error, new Date(Date.now() + TIME_BETWEEN_ATTEMPTS));
} finally {
this.reporting.untrackReport(jobId);
logger.debug(`Reports running: ${this.reporting.countConcurrentReports()}.`);
this.opts.reporting.untrackReport(jobId);
logger.debug(`Reports running: ${this.opts.reporting.countConcurrentReports()}.`);
}
},
@ -642,47 +626,4 @@ export class ExecuteReportTask implements ReportingTask {
};
};
}
private getMaxAttempts() {
return this.config.capture.maxAttempts ?? 1;
}
public getTaskDefinition() {
// round up from ms to the nearest second
const queueTimeout = Math.ceil(numberToDuration(this.config.queue.timeout).asSeconds()) + 's';
const maxConcurrency = this.config.queue.pollEnabled ? 1 : 0;
const maxAttempts = this.getMaxAttempts();
return {
type: REPORTING_EXECUTE_TYPE,
title: 'Reporting: execute job',
createTaskRunner: this.getTaskRunner(),
maxAttempts: maxAttempts + 1, // Add 1 so we get an extra attempt in case of failure during a Kibana restart
timeout: queueTimeout,
maxConcurrency,
};
}
public async scheduleTask(request: KibanaRequest, params: ReportTaskParams) {
const reportingHealth = await this.reporting.getHealthInfo();
const shouldScheduleWithApiKey =
reportingHealth.hasPermanentEncryptionKey && reportingHealth.isSufficientlySecure;
const taskInstance: ReportingExecuteTaskInstance = {
taskType: REPORTING_EXECUTE_TYPE,
state: {},
params,
};
return shouldScheduleWithApiKey
? await this.getTaskManagerStart().schedule(taskInstance, { request })
: await this.getTaskManagerStart().schedule(taskInstance);
}
public getStatus() {
if (this.taskManagerStart) {
return ReportingTaskStatus.INITIALIZED;
}
return ReportingTaskStatus.UNINITIALIZED;
}
}

View file

@ -0,0 +1,709 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Transform } from 'stream';
import type { estypes } from '@elastic/elasticsearch';
import { loggingSystemMock } from '@kbn/core/server/mocks';
import { MockedLogger } from '@kbn/logging-mocks';
import { JOB_STATUS, KibanaShuttingDownError } from '@kbn/reporting-common';
import { ReportDocument } from '@kbn/reporting-common/types';
import { createMockConfigSchema } from '@kbn/reporting-mocks-server';
import { type ExportType, type ReportingConfigType } from '@kbn/reporting-server';
import type { RunContext } from '@kbn/task-manager-plugin/server';
import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks';
import { notificationsMock } from '@kbn/notifications-plugin/server/mocks';
import { RunScheduledReportTask, SCHEDULED_REPORTING_EXECUTE_TYPE } from '.';
import { ReportingCore } from '../..';
import { createMockReportingCore } from '../../test_helpers';
import {
FakeRawRequest,
KibanaRequest,
SavedObject,
SavedObjectsClientContract,
} from '@kbn/core/server';
import { Frequency } from '@kbn/rrule';
import { ReportingStore, SavedReport } from '../store';
import { ScheduledReportType } from '../../types';
import { EmailNotificationService } from '../../services/notifications/email_notification_service';
interface StreamMock {
getSeqNo: () => number;
getPrimaryTerm: () => number;
write: (data: string) => void;
fail: () => void;
end: () => void;
transform: Transform;
}
function createStreamMock(): StreamMock {
const transform: Transform = new Transform({});
return {
getSeqNo: () => 10,
getPrimaryTerm: () => 20,
write: (data: string) => {
transform.push(`${data}\n`);
},
fail: () => {
transform.emit('error', new Error('Stream failed'));
transform.end();
},
transform,
end: () => {
transform.end();
},
};
}
const mockStream = createStreamMock();
jest.mock('../content_stream', () => ({
getContentStream: () => mockStream,
finishedWithNoPendingCallbacks: () => Promise.resolve(),
}));
jest.mock('../../services/notifications/email_notification_service');
const fakeRawRequest: FakeRawRequest = {
headers: {
authorization: `ApiKey skdjtq4u543yt3rhewrh`,
},
path: '/',
};
const payload = {
headers: '',
title: 'Test Report',
browserTimezone: '',
objectType: 'test',
version: '8.0.0',
};
const scheduledReport: SavedObject<ScheduledReportType> = {
id: 'report-so-id',
attributes: {
createdAt: new Date().toISOString(),
createdBy: 'test-user',
enabled: true,
jobType: 'test1',
meta: { objectType: 'test' },
migrationVersion: '8.0.0',
payload: JSON.stringify(payload),
schedule: { rrule: { freq: Frequency.DAILY, interval: 2, tzid: 'UTC' } },
title: 'Test Report',
notification: {
email: {
to: ['test1@test.com'],
bcc: ['test2@test.com'],
},
},
},
references: [],
type: 'scheduled-report',
};
const savedReport = new SavedReport({
_id: '290357209345723095',
_index: '.reporting-fantastic',
_seq_no: 23,
_primary_term: 354000,
jobtype: 'test1',
migration_version: '8.0.0',
payload,
created_at: new Date().toISOString(),
created_by: 'test-user',
meta: { objectType: 'test' },
scheduled_report_id: 'report-so-id',
status: JOB_STATUS.PROCESSING,
});
describe('Run Scheduled Report Task', () => {
let mockReporting: ReportingCore;
let configType: ReportingConfigType;
let soClient: SavedObjectsClientContract;
let reportStore: ReportingStore;
const notifications = notificationsMock.createStart();
let emailNotificationService: EmailNotificationService;
let logger: MockedLogger;
const runTaskFn = jest.fn().mockResolvedValue({ content_type: 'application/pdf' });
beforeAll(async () => {
configType = createMockConfigSchema();
mockReporting = await createMockReportingCore(configType);
soClient = await mockReporting.getInternalSoClient();
mockReporting.getExportTypesRegistry().register({
id: 'test1',
name: 'Test1',
setup: jest.fn(),
start: jest.fn(),
createJob: () => new Promise(() => {}),
runTask: runTaskFn,
jobContentEncoding: 'base64',
jobContentExtension: 'pdf',
jobType: 'test1',
validLicenses: [],
} as unknown as ExportType);
});
beforeEach(async () => {
logger = loggingSystemMock.createLogger();
soClient.get = jest.fn().mockImplementation(async () => {
return scheduledReport;
});
reportStore = await mockReporting.getStore();
reportStore.addReport = jest.fn().mockImplementation(async () => {
return savedReport;
});
reportStore.setReportError = jest.fn(() =>
Promise.resolve({
_id: 'test',
jobtype: 'noop',
status: 'processing',
} as unknown as estypes.UpdateUpdateWriteResponseBase<ReportDocument>)
);
reportStore.setReportWarning = jest.fn();
emailNotificationService = new EmailNotificationService({
notifications,
});
});
it('Instance setup', () => {
const task = new RunScheduledReportTask({
reporting: mockReporting,
config: configType,
logger,
});
expect(task.getStatus()).toBe('uninitialized');
expect(task.getTaskDefinition()).toMatchInlineSnapshot(`
Object {
"createTaskRunner": [Function],
"maxConcurrency": 1,
"timeout": "120s",
"title": "Reporting: execute scheduled job",
"type": "report:execute-scheduled",
}
`);
});
it('Instance start', () => {
const mockTaskManager = taskManagerMock.createStart();
const task = new RunScheduledReportTask({
reporting: mockReporting,
config: configType,
logger,
});
expect(task.init(mockTaskManager, emailNotificationService));
expect(task.getStatus()).toBe('initialized');
});
it('create task runner', async () => {
logger.info = jest.fn();
logger.error = jest.fn();
const task = new RunScheduledReportTask({
reporting: mockReporting,
config: configType,
logger,
});
const taskDef = task.getTaskDefinition();
const taskRunner = taskDef.createTaskRunner({
taskInstance: {
id: 'random-task-id',
params: { id: 'cool-reporting-id', jobtype: 'test1' },
},
} as unknown as RunContext);
expect(taskRunner).toHaveProperty('run');
expect(taskRunner).toHaveProperty('cancel');
});
it('Max Concurrency is 0 if pollEnabled is false', () => {
const queueConfig = {
queue: { pollEnabled: false, timeout: 55000 },
} as unknown as ReportingConfigType['queue'];
const task = new RunScheduledReportTask({
reporting: mockReporting,
config: { ...configType, ...queueConfig },
logger,
});
expect(task.getStatus()).toBe('uninitialized');
expect(task.getTaskDefinition()).toMatchInlineSnapshot(`
Object {
"createTaskRunner": [Function],
"maxConcurrency": 0,
"timeout": "55s",
"title": "Reporting: execute scheduled job",
"type": "report:execute-scheduled",
}
`);
});
it('schedules task with request', async () => {
const task = new RunScheduledReportTask({
reporting: mockReporting,
config: configType,
logger,
});
const mockTaskManager = taskManagerMock.createStart();
await task.init(mockTaskManager, emailNotificationService);
await task.scheduleTask(fakeRawRequest as unknown as KibanaRequest, {
id: 'report-so-id',
jobtype: 'test1',
schedule: {
rrule: { freq: Frequency.DAILY, interval: 2, tzid: 'UTC' },
} as never,
});
expect(mockTaskManager.schedule).toHaveBeenCalledWith(
{
id: 'report-so-id',
taskType: SCHEDULED_REPORTING_EXECUTE_TYPE,
state: {},
params: {
id: 'report-so-id',
spaceId: 'default',
jobtype: 'test1',
},
schedule: {
rrule: { freq: Frequency.DAILY, interval: 2, tzid: 'UTC' },
},
},
{ request: fakeRawRequest }
);
});
it('uses authorization headers from task manager fake request', async () => {
const runAt = new Date('2023-10-01T00:00:00Z');
const task = new RunScheduledReportTask({
reporting: mockReporting,
config: configType,
logger,
});
jest
// @ts-expect-error TS compilation fails: this overrides a private method of the RunScheduledReportTask instance
.spyOn(task, 'completeJob')
.mockResolvedValueOnce({ _id: 'test', jobtype: 'test1', status: 'pending' } as never);
const mockTaskManager = taskManagerMock.createStart();
await task.init(mockTaskManager, emailNotificationService);
const taskDef = task.getTaskDefinition();
const taskRunner = taskDef.createTaskRunner({
taskInstance: {
id: 'report-so-id',
runAt,
params: {
id: 'report-so-id',
jobtype: 'test1',
schedule: {
rrule: { freq: Frequency.DAILY, interval: 2, tzid: 'UTC' },
},
},
},
fakeRequest: fakeRawRequest,
} as unknown as RunContext);
await taskRunner.run();
expect(soClient.get).toHaveBeenCalledWith('scheduled_report', 'report-so-id', {
namespace: 'default',
});
expect(reportStore.addReport).toHaveBeenCalledWith(
expect.objectContaining({
_id: expect.any(String),
_index: '.kibana-reporting',
jobtype: 'test1',
created_at: expect.any(String),
created_by: 'test-user',
payload: {
headers: '',
title: expect.any(String),
browserTimezone: '',
objectType: 'test',
version: '8.0.0',
forceNow: expect.any(String),
},
meta: { objectType: 'test' },
status: 'processing',
attempts: 1,
scheduled_report_id: 'report-so-id',
kibana_name: 'kibana',
kibana_id: 'instance-uuid',
started_at: expect.any(String),
timeout: 120000,
max_attempts: 1,
process_expiration: expect.any(String),
migration_version: '7.14.0',
})
);
expect(runTaskFn.mock.calls[0][0].request.headers).toEqual({
authorization: 'ApiKey skdjtq4u543yt3rhewrh',
});
});
it('throws if no fake request from task', async () => {
const task = new RunScheduledReportTask({
reporting: mockReporting,
config: configType,
logger,
});
const mockTaskManager = taskManagerMock.createStart();
await task.init(mockTaskManager, emailNotificationService);
const taskDef = task.getTaskDefinition();
const taskRunner = taskDef.createTaskRunner({
taskInstance: {
id: 'report-so-id',
runAt: new Date('2023-10-01T00:00:00Z'),
params: {
id: 'report-so-id',
jobtype: 'test1',
schedule: {
rrule: { freq: Frequency.DAILY, interval: 2, tzid: 'UTC' },
},
},
},
fakeRequest: undefined,
} as unknown as RunContext);
await expect(taskRunner.run()).rejects.toThrowErrorMatchingInlineSnapshot(
`"ReportingError(code: missing_authentication_header_error)"`
);
expect(soClient.get).toHaveBeenCalled();
expect(reportStore.addReport).toHaveBeenCalled();
expect(reportStore.setReportError).toHaveBeenLastCalledWith(
expect.objectContaining({
_id: '290357209345723095',
}),
expect.objectContaining({
error: expect.objectContaining({
message: `ReportingError(code: missing_authentication_header_error)`,
}),
})
);
});
it('throws during reporting if Kibana starts shutting down', async () => {
mockReporting.getExportTypesRegistry().register({
id: 'noop',
name: 'Noop',
setup: jest.fn(),
start: jest.fn(),
createJob: () => new Promise(() => {}),
runTask: () => new Promise(() => {}),
jobContentExtension: 'pdf',
jobType: 'noop',
validLicenses: [],
} as unknown as ExportType);
const task = new RunScheduledReportTask({
reporting: mockReporting,
config: configType,
logger,
});
jest
// @ts-expect-error TS compilation fails: this overrides a private method of the RunScheduledReportTask instance
.spyOn(task, 'prepareJob')
.mockResolvedValueOnce({
isLastAttempt: false,
jobId: '290357209345723095',
report: { _id: '290357209345723095', jobtype: 'noop' },
task: {
id: '290357209345723095',
index: '.reporting-fantastic',
jobtype: 'noop',
payload,
},
} as never);
const mockTaskManager = taskManagerMock.createStart();
await task.init(mockTaskManager, emailNotificationService);
const taskDef = task.getTaskDefinition();
const taskRunner = taskDef.createTaskRunner({
taskInstance: {
id: 'report-so-id',
params: {
id: 'report-so-id',
jobtype: 'test1',
schedule: {
rrule: { freq: Frequency.DAILY, interval: 2, tzid: 'UTC' },
},
},
},
fakeRequest: fakeRawRequest,
} as unknown as RunContext);
const taskPromise = taskRunner.run();
setImmediate(() => {
mockReporting.pluginStop();
});
await taskPromise.catch(() => {});
expect(reportStore.setReportError).toHaveBeenLastCalledWith(
expect.objectContaining({
_id: '290357209345723095',
}),
expect.objectContaining({
error: expect.objectContaining({
message: `ReportingError(code: ${new KibanaShuttingDownError().code})`,
}),
})
);
});
describe('notify', () => {
it('sends an email notification', async () => {
const task = new RunScheduledReportTask({
reporting: mockReporting,
config: configType,
logger,
});
const mockTaskManager = taskManagerMock.createStart();
await task.init(mockTaskManager, emailNotificationService);
const taskInstance = {
id: 'task-id',
runAt: new Date('2025-06-04T00:00:00Z'),
params: { id: 'report-so-id', jobtype: 'test1' },
};
const byteSize = 2097152; // 2MB
const output = { content_type: 'application/pdf' };
// @ts-expect-error
await task.notify(savedReport, taskInstance, output, byteSize, scheduledReport, 'default');
expect(soClient.get).not.toHaveBeenCalled();
expect(emailNotificationService.notify).toHaveBeenCalledWith({
contentType: 'application/pdf',
emailParams: {
bcc: ['test2@test.com'],
cc: undefined,
spaceId: 'default',
subject: 'Test Report-2025-06-04T00:00:00.000Z scheduled report',
to: ['test1@test.com'],
},
filename: 'Test Report-2025-06-04T00:00:00.000Z.pdf',
id: '290357209345723095',
index: '.reporting-fantastic',
relatedObject: {
id: 'report-so-id',
namespace: 'default',
type: 'scheduled-report',
},
reporting: mockReporting,
});
});
it("gets the scheduled_report saved object if it's not defined", async () => {
const task = new RunScheduledReportTask({
reporting: mockReporting,
config: configType,
logger,
});
const mockTaskManager = taskManagerMock.createStart();
await task.init(mockTaskManager, emailNotificationService);
const taskInstance = {
id: 'task-id',
runAt: new Date('2025-06-04T00:00:00Z'),
params: { id: 'report-so-id', jobtype: 'test1' },
};
const byteSize = 2097152; // 2MB
const output = { content_type: 'application/pdf' };
// @ts-expect-error
await task.notify(savedReport, taskInstance, output, byteSize, undefined, 'default');
expect(soClient.get).toHaveBeenCalled();
expect(emailNotificationService.notify).toHaveBeenCalledWith({
contentType: 'application/pdf',
emailParams: {
bcc: ['test2@test.com'],
cc: undefined,
spaceId: 'default',
subject: 'Test Report-2025-06-04T00:00:00.000Z scheduled report',
to: ['test1@test.com'],
},
filename: 'Test Report-2025-06-04T00:00:00.000Z.pdf',
id: '290357209345723095',
index: '.reporting-fantastic',
relatedObject: {
id: 'report-so-id',
namespace: 'default',
type: 'scheduled-report',
},
reporting: mockReporting,
});
});
it('does not send an email notification if the notification is not defined', async () => {
const task = new RunScheduledReportTask({
reporting: mockReporting,
config: configType,
logger,
});
const mockTaskManager = taskManagerMock.createStart();
await task.init(mockTaskManager, emailNotificationService);
const taskInstance = {
id: 'task-id',
runAt: new Date('2025-06-04T00:00:00Z'),
params: { id: 'report-so-id', jobtype: 'test1' },
};
const byteSize = 2097152; // 2MB
const output = { content_type: 'application/pdf' };
const noNotification = { ...scheduledReport, notification: undefined };
// @ts-expect-error
await task.notify(savedReport, taskInstance, output, byteSize, noNotification, 'default');
expect(soClient.get).not.toHaveBeenCalled();
expect(emailNotificationService.notify).not.toHaveBeenCalledWith();
});
it('logs a warning and sets the execution to warning when the report is larger than 10MB', async () => {
const task = new RunScheduledReportTask({
reporting: mockReporting,
config: configType,
logger,
});
const mockTaskManager = taskManagerMock.createStart();
await task.init(mockTaskManager, emailNotificationService);
const taskInstance = {
id: 'task-id',
runAt: new Date('2025-06-04T00:00:00Z'),
params: { id: 'report-so-id', jobtype: 'test1' },
};
const byteSize = 11534336; // 11MB
const output = { content_type: 'application/pdf' };
// @ts-expect-error
await task.notify(savedReport, taskInstance, output, byteSize, scheduledReport, 'default');
await new Promise((resolve) => setTimeout(resolve, 0));
expect(emailNotificationService.notify).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
'Error sending notification for scheduled report: The report is larger than the 10MB limit.'
);
expect(reportStore.setReportWarning).toHaveBeenCalledWith(savedReport, {
output: { content_type: 'application/pdf', size: 11534336 },
warning:
'Error sending notification for scheduled report: The report is larger than the 10MB limit.',
});
});
it('logs a warning and sets the execution to warning when the notification service is not initialized', async () => {
const task = new RunScheduledReportTask({
reporting: mockReporting,
config: configType,
logger,
});
const mockTaskManager = taskManagerMock.createStart();
await task.init(mockTaskManager);
const taskInstance = {
id: 'task-id',
runAt: new Date('2025-06-04T00:00:00Z'),
params: { id: 'report-so-id', jobtype: 'test1' },
};
const byteSize = 2097152; // 2MB
const output = { content_type: 'application/pdf' };
// @ts-expect-error
await task.notify(savedReport, taskInstance, output, byteSize, scheduledReport, 'default');
await new Promise((resolve) => setTimeout(resolve, 0));
expect(emailNotificationService.notify).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
'Error sending notification for scheduled report: Reporting notification service has not been initialized.'
);
expect(reportStore.setReportWarning).toHaveBeenCalledWith(savedReport, {
output: { content_type: 'application/pdf', size: 2097152 },
warning:
'Error sending notification for scheduled report: Reporting notification service has not been initialized.',
});
});
it('logs a warning and sets the execution to warning if the notification service throws an error', async () => {
jest
.spyOn(emailNotificationService, 'notify')
.mockRejectedValueOnce(new Error('This is a test error!'));
const task = new RunScheduledReportTask({
reporting: mockReporting,
config: configType,
logger,
});
const mockTaskManager = taskManagerMock.createStart();
await task.init(mockTaskManager, emailNotificationService);
const taskInstance = {
id: 'task-id',
runAt: new Date('2025-06-04T00:00:00Z'),
params: { id: 'report-so-id', jobtype: 'test1' },
};
const byteSize = 2097152; // 2MB
const output = { content_type: 'application/pdf' };
// @ts-expect-error
await task.notify(savedReport, taskInstance, output, byteSize, scheduledReport, 'default');
await new Promise((resolve) => setTimeout(resolve, 0));
expect(emailNotificationService.notify).toHaveBeenCalledWith({
contentType: 'application/pdf',
emailParams: {
bcc: ['test2@test.com'],
cc: undefined,
spaceId: 'default',
subject: 'Test Report-2025-06-04T00:00:00.000Z scheduled report',
to: ['test1@test.com'],
},
filename: 'Test Report-2025-06-04T00:00:00.000Z.pdf',
id: '290357209345723095',
index: '.reporting-fantastic',
relatedObject: {
id: 'report-so-id',
namespace: 'default',
type: 'scheduled-report',
},
reporting: mockReporting,
});
expect(logger.warn).toHaveBeenCalledWith(
'Error sending notification for scheduled report: This is a test error!'
);
expect(reportStore.setReportWarning).toHaveBeenCalledWith(savedReport, {
output: { content_type: 'application/pdf', size: 2097152 },
warning: 'Error sending notification for scheduled report: This is a test error!',
});
});
it('logs an error if there is an error thrown setting execution to warning', async () => {
jest
.spyOn(reportStore, 'setReportWarning')
.mockRejectedValueOnce('Error setting status to warning');
const task = new RunScheduledReportTask({
reporting: mockReporting,
config: configType,
logger,
});
const mockTaskManager = taskManagerMock.createStart();
await task.init(mockTaskManager, emailNotificationService);
const taskInstance = {
id: 'task-id',
runAt: new Date('2025-06-04T00:00:00Z'),
params: { id: 'report-so-id', jobtype: 'test1' },
};
const byteSize = 11534336; // 11MB
const output = { content_type: 'application/pdf' };
// @ts-expect-error
await task.notify(savedReport, taskInstance, output, byteSize, scheduledReport, 'default');
await new Promise((resolve) => setTimeout(resolve, 0));
expect(emailNotificationService.notify).not.toHaveBeenCalled();
expect(logger.error).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,201 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { KibanaRequest, SavedObject } from '@kbn/core/server';
import type { TaskRunResult } from '@kbn/reporting-common/types';
import type { ConcreteTaskInstance, TaskInstance } from '@kbn/task-manager-plugin/server';
import { DEFAULT_SPACE_ID } from '@kbn/spaces-utils';
import {
SCHEDULED_REPORTING_EXECUTE_TYPE,
ScheduledReportTaskParams,
ScheduledReportTaskParamsWithoutSpaceId,
} from '.';
import type { SavedReport } from '../store';
import { errorLogger } from './error_logger';
import { SCHEDULED_REPORT_SAVED_OBJECT_TYPE } from '../../saved_objects';
import { PrepareJobResults, RunReportTask } from './run_report';
import { ScheduledReport } from '../store/scheduled_report';
import { ScheduledReportType } from '../../types';
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; // 10mb
type ScheduledReportTaskInstance = Omit<TaskInstance, 'params'> & {
params: Omit<ScheduledReportTaskParams, 'schedule'>;
};
export class RunScheduledReportTask extends RunReportTask<ScheduledReportTaskParams> {
public get TYPE() {
return SCHEDULED_REPORTING_EXECUTE_TYPE;
}
protected async prepareJob(taskInstance: ConcreteTaskInstance): Promise<PrepareJobResults> {
const { runAt, params: scheduledReportTaskParams } = taskInstance;
let report: SavedReport | undefined;
let jobId: string;
let scheduledReport: SavedObject<ScheduledReportType> | undefined;
const task = scheduledReportTaskParams as ScheduledReportTaskParams;
const scheduledReportId = task.id;
const reportSpaceId = task.spaceId || DEFAULT_SPACE_ID;
try {
if (!scheduledReportId) {
throw new Error(
`Invalid scheduled_report saved object data provided in scheduled task! - No saved object with id "${scheduledReportId}"`
);
}
const internalSoClient = await this.opts.reporting.getInternalSoClient();
scheduledReport = await internalSoClient.get<ScheduledReportType>(
SCHEDULED_REPORT_SAVED_OBJECT_TYPE,
scheduledReportId,
{ namespace: reportSpaceId }
);
const store = await this.opts.reporting.getStore();
// Add the report to ReportingStore to show as processing
report = await store.addReport(
new ScheduledReport({
runAt,
kibanaId: this.kibanaId!,
kibanaName: this.kibanaName!,
queueTimeout: this.queueTimeout,
scheduledReport,
})
);
jobId = report._id;
if (!jobId) {
throw new Error(`Unable to store report document in ReportingStore`);
}
} catch (failedToClaim) {
// error claiming report - log the error
errorLogger(
this.logger,
`Error in running scheduled report ${scheduledReportId}`,
failedToClaim
);
}
return {
isLastAttempt: false,
jobId: jobId!,
report,
task: report?.toReportTaskJSON(),
scheduledReport,
};
}
protected getMaxAttempts() {
return undefined;
}
protected async notify(
report: SavedReport,
taskInstance: ConcreteTaskInstance,
output: TaskRunResult,
byteSize: number,
scheduledReport?: SavedObject<ScheduledReportType>,
spaceId?: string
): Promise<void> {
try {
const { runAt, params } = taskInstance;
const task = params as ScheduledReportTaskParams;
if (!scheduledReport) {
const internalSoClient = await this.opts.reporting.getInternalSoClient();
scheduledReport = await internalSoClient.get<ScheduledReportType>(
SCHEDULED_REPORT_SAVED_OBJECT_TYPE,
task.id,
{ namespace: spaceId }
);
}
const { notification } = scheduledReport.attributes;
if (notification && notification.email) {
if (byteSize > MAX_ATTACHMENT_SIZE) {
throw new Error('The report is larger than the 10MB limit.');
}
if (!this.emailNotificationService) {
throw new Error('Reporting notification service has not been initialized.');
}
const email = notification.email;
const title = scheduledReport.attributes.title;
const extension = this.getJobContentExtension(report.jobtype);
await this.emailNotificationService.notify({
reporting: this.opts.reporting,
index: report._index,
id: report._id,
filename: `${title}-${runAt.toISOString()}.${extension}`,
contentType: output.content_type,
relatedObject: {
id: scheduledReport.id,
type: scheduledReport.type,
namespace: spaceId,
},
emailParams: {
to: email.to,
cc: email.cc,
bcc: email.bcc,
subject: `${title}-${runAt.toISOString()} scheduled report`,
spaceId,
},
});
}
} catch (error) {
const message = `Error sending notification for scheduled report: ${error.message}`;
this.saveExecutionWarning(
report,
{
...output,
size: byteSize,
},
message
).catch((failedToSaveWarning) => {
errorLogger(
this.logger,
`Error in saving execution warning ${report._id}`,
failedToSaveWarning
);
});
}
}
public getTaskDefinition() {
const queueTimeout = this.getQueueTimeout();
const maxConcurrency = this.getMaxConcurrency();
return {
type: SCHEDULED_REPORTING_EXECUTE_TYPE,
title: 'Reporting: execute scheduled job',
createTaskRunner: this.getTaskRunner(),
timeout: queueTimeout,
maxConcurrency,
};
}
public async scheduleTask(
request: KibanaRequest,
params: ScheduledReportTaskParamsWithoutSpaceId
) {
const spaceId = this.opts.reporting.getSpaceId(request, this.logger);
const taskInstance: ScheduledReportTaskInstance = {
id: params.id,
taskType: SCHEDULED_REPORTING_EXECUTE_TYPE,
state: {},
params: {
id: params.id,
spaceId: spaceId || DEFAULT_SPACE_ID,
jobtype: params.jobtype,
},
schedule: params.schedule,
};
return await this.getTaskManagerStart().schedule(taskInstance, { request });
}
}

View file

@ -16,7 +16,7 @@ import { cryptoFactory, type ExportType, type ReportingConfigType } from '@kbn/r
import type { RunContext } from '@kbn/task-manager-plugin/server';
import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks';
import { ExecuteReportTask, REPORTING_EXECUTE_TYPE } from '.';
import { RunSingleReportTask, REPORTING_EXECUTE_TYPE } from '.';
import { ReportingCore } from '../..';
import { createMockReportingCore } from '../../test_helpers';
import { FakeRawRequest, KibanaRequest } from '@kbn/core/server';
@ -104,7 +104,7 @@ const fakeRawRequest: FakeRawRequest = {
path: '/',
};
describe('Execute Report Task', () => {
describe('Run Single Report Task', () => {
let mockReporting: ReportingCore;
let configType: ReportingConfigType;
beforeAll(async () => {
@ -113,7 +113,7 @@ describe('Execute Report Task', () => {
});
it('Instance setup', () => {
const task = new ExecuteReportTask(mockReporting, configType, logger);
const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger });
expect(task.getStatus()).toBe('uninitialized');
expect(task.getTaskDefinition()).toMatchInlineSnapshot(`
Object {
@ -129,7 +129,7 @@ describe('Execute Report Task', () => {
it('Instance start', () => {
const mockTaskManager = taskManagerMock.createStart();
const task = new ExecuteReportTask(mockReporting, configType, logger);
const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger });
expect(task.init(mockTaskManager));
expect(task.getStatus()).toBe('initialized');
});
@ -138,7 +138,7 @@ describe('Execute Report Task', () => {
logger.info = jest.fn();
logger.error = jest.fn();
const task = new ExecuteReportTask(mockReporting, configType, logger);
const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger });
const taskDef = task.getTaskDefinition();
const taskRunner = taskDef.createTaskRunner({
taskInstance: {
@ -155,7 +155,11 @@ describe('Execute Report Task', () => {
queue: { pollEnabled: false, timeout: 55000 },
} as unknown as ReportingConfigType['queue'];
const task = new ExecuteReportTask(mockReporting, { ...configType, ...queueConfig }, logger);
const task = new RunSingleReportTask({
reporting: mockReporting,
config: { ...configType, ...queueConfig },
logger,
});
expect(task.getStatus()).toBe('uninitialized');
expect(task.getTaskDefinition()).toMatchInlineSnapshot(`
Object {
@ -175,7 +179,7 @@ describe('Execute Report Task', () => {
hasPermanentEncryptionKey: true,
areNotificationsEnabled: true,
});
const task = new ExecuteReportTask(mockReporting, configType, logger);
const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger });
const mockTaskManager = taskManagerMock.createStart();
await task.init(mockTaskManager);
@ -208,7 +212,7 @@ describe('Execute Report Task', () => {
hasPermanentEncryptionKey: true,
areNotificationsEnabled: false,
});
const task = new ExecuteReportTask(mockReporting, configType, logger);
const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger });
const mockTaskManager = taskManagerMock.createStart();
await task.init(mockTaskManager);
@ -238,7 +242,7 @@ describe('Execute Report Task', () => {
hasPermanentEncryptionKey: false,
areNotificationsEnabled: true,
});
const task = new ExecuteReportTask(mockReporting, configType, logger);
const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger });
const mockTaskManager = taskManagerMock.createStart();
await task.init(mockTaskManager);
@ -275,14 +279,14 @@ describe('Execute Report Task', () => {
jobType: 'test1',
validLicenses: [],
} as unknown as ExportType);
const task = new ExecuteReportTask(mockReporting, configType, logger);
const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger });
jest
// @ts-expect-error TS compilation fails: this overrides a private method of the ExecuteReportTask instance
.spyOn(task, '_claimJob')
// @ts-expect-error TS compilation fails: this overrides a private method of the RunSingleReportTask instance
.spyOn(task, 'claimJob')
.mockResolvedValueOnce({ _id: 'test', jobtype: 'test1', status: 'pending' } as never);
jest
// @ts-expect-error TS compilation fails: this overrides a private method of the ExecuteReportTask instance
.spyOn(task, '_completeJob')
// @ts-expect-error TS compilation fails: this overrides a private method of the RunSingleReportTask instance
.spyOn(task, 'completeJob')
.mockResolvedValueOnce({ _id: 'test', jobtype: 'test1', status: 'pending' } as never);
const mockTaskManager = taskManagerMock.createStart();
await task.init(mockTaskManager);
@ -320,14 +324,14 @@ describe('Execute Report Task', () => {
jobType: 'test2',
validLicenses: [],
} as unknown as ExportType);
const task = new ExecuteReportTask(mockReporting, configType, logger);
const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger });
jest
// @ts-expect-error TS compilation fails: this overrides a private method of the ExecuteReportTask instance
.spyOn(task, '_claimJob')
// @ts-expect-error TS compilation fails: this overrides a private method of the RunSingleReportTask instance
.spyOn(task, 'claimJob')
.mockResolvedValueOnce({ _id: 'test', jobtype: 'test2', status: 'pending' } as never);
jest
// @ts-expect-error TS compilation fails: this overrides a private method of the ExecuteReportTask instance
.spyOn(task, '_completeJob')
// @ts-expect-error TS compilation fails: this overrides a private method of the RunSingleReportTask instance
.spyOn(task, 'completeJob')
.mockResolvedValueOnce({ _id: 'test', jobtype: 'test2', status: 'pending' } as never);
const mockTaskManager = taskManagerMock.createStart();
await task.init(mockTaskManager);
@ -367,14 +371,14 @@ describe('Execute Report Task', () => {
jobType: 'test3',
validLicenses: [],
} as unknown as ExportType);
const task = new ExecuteReportTask(mockReporting, configType, logger);
const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger });
jest
// @ts-expect-error TS compilation fails: this overrides a private method of the ExecuteReportTask instance
.spyOn(task, '_claimJob')
// @ts-expect-error TS compilation fails: this overrides a private method of the RunSingleReportTask instance
.spyOn(task, 'claimJob')
.mockResolvedValueOnce({ _id: 'test', jobtype: 'test3', status: 'pending' } as never);
jest
// @ts-expect-error TS compilation fails: this overrides a private method of the ExecuteReportTask instance
.spyOn(task, '_completeJob')
// @ts-expect-error TS compilation fails: this overrides a private method of the RunSingleReportTask instance
.spyOn(task, 'completeJob')
.mockResolvedValueOnce({ _id: 'test', jobtype: 'test3', status: 'pending' } as never);
const mockTaskManager = taskManagerMock.createStart();
await task.init(mockTaskManager);
@ -421,10 +425,10 @@ describe('Execute Report Task', () => {
status: 'processing',
} as unknown as estypes.UpdateUpdateWriteResponseBase<ReportDocument>)
);
const task = new ExecuteReportTask(mockReporting, configType, logger);
const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger });
jest
// @ts-expect-error TS compilation fails: this overrides a private method of the ExecuteReportTask instance
.spyOn(task, '_claimJob')
// @ts-expect-error TS compilation fails: this overrides a private method of the RunSingleReportTask instance
.spyOn(task, 'claimJob')
.mockResolvedValueOnce({ _id: 'test', jobtype: 'noop', status: 'pending' } as never);
const mockTaskManager = taskManagerMock.createStart();
await task.init(mockTaskManager);

View file

@ -0,0 +1,160 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import moment from 'moment';
import type { KibanaRequest } from '@kbn/core/server';
import { QueueTimeoutError, ReportingError } from '@kbn/reporting-common';
import type { ConcreteTaskInstance, TaskInstance } from '@kbn/task-manager-plugin/server';
import { REPORTING_EXECUTE_TYPE, ReportTaskParams } from '.';
import {
isExecutionError,
mapToReportingError,
} from '../../../common/errors/map_to_reporting_error';
import { SavedReport } from '../store';
import type { ReportProcessingFields } from '../store/store';
import { errorLogger } from './error_logger';
import { PrepareJobResults, RunReportTask } from './run_report';
type SingleReportTaskInstance = Omit<TaskInstance, 'params'> & {
params: ReportTaskParams;
};
export class RunSingleReportTask extends RunReportTask<ReportTaskParams> {
public get TYPE() {
return REPORTING_EXECUTE_TYPE;
}
private async claimJob(task: ReportTaskParams): Promise<SavedReport> {
const store = await this.opts.reporting.getStore();
const report = await store.findReportFromTask(task); // receives seq_no and primary_term
const logger = this.logger.get(report._id);
if (report.status === 'completed') {
throw new Error(`Can not claim the report job: it is already completed!`);
}
const m = moment();
// check if job has exceeded the configured maxAttempts
const maxAttempts = this.getMaxAttempts();
if (report.attempts >= maxAttempts) {
let err: ReportingError;
if (report.error && isExecutionError(report.error)) {
// We have an error stored from a previous attempts, so we'll use that
// error to fail the job and return it to the user.
const { error } = report;
err = mapToReportingError(error);
err.stack = error.stack;
} else {
if (report.error && report.error instanceof Error) {
errorLogger(logger, 'Error executing report', report.error);
}
err = new QueueTimeoutError(
`Max attempts reached (${maxAttempts}). Queue timeout reached.`
);
}
await this.failJob(report, err);
throw err;
}
const startTime = m.toISOString();
const expirationTime = m.add(this.queueTimeout).toISOString();
const doc: ReportProcessingFields = {
kibana_id: this.kibanaId,
kibana_name: this.kibanaName,
attempts: report.attempts + 1,
max_attempts: maxAttempts,
started_at: startTime,
timeout: this.queueTimeout,
process_expiration: expirationTime,
};
const claimedReport = new SavedReport({ ...report, ...doc });
logger.info(
`Claiming ${claimedReport.jobtype} ${report._id} ` +
`[_index: ${report._index}] ` +
`[_seq_no: ${report._seq_no}] ` +
`[_primary_term: ${report._primary_term}] ` +
`[attempts: ${report.attempts}] ` +
`[process_expiration: ${expirationTime}]`
);
// event tracking of claimed job
const eventTracker = this.getEventTracker(report);
const timeSinceCreation = Date.now() - new Date(report.created_at).valueOf();
eventTracker?.claimJob({ timeSinceCreation });
const resp = await store.setReportClaimed(claimedReport, doc);
claimedReport._seq_no = resp._seq_no!;
claimedReport._primary_term = resp._primary_term!;
return claimedReport;
}
protected async prepareJob(taskInstance: ConcreteTaskInstance): Promise<PrepareJobResults> {
const { attempts: taskAttempts, params: reportTaskParams } = taskInstance;
let report: SavedReport | undefined;
const isLastAttempt = taskAttempts >= this.getMaxAttempts();
// find the job in the store and set status to processing
const task = reportTaskParams as ReportTaskParams;
const jobId = task?.id;
try {
if (!jobId) {
throw new Error('Invalid report data provided in scheduled task!');
}
// Update job status to claimed
report = await this.claimJob(task);
} catch (failedToClaim) {
// error claiming report - log the error
// could be version conflict, or too many attempts or no longer connected to ES
errorLogger(this.logger, `Error in claiming ${jobId}`, failedToClaim);
}
return { isLastAttempt, jobId, report, task };
}
protected getMaxAttempts() {
return this.opts.config.capture.maxAttempts ?? 1;
}
protected async notify(): Promise<void> {}
public getTaskDefinition() {
const queueTimeout = this.getQueueTimeout();
const maxConcurrency = this.getMaxConcurrency();
const maxAttempts = this.getMaxAttempts();
return {
type: REPORTING_EXECUTE_TYPE,
title: 'Reporting: execute job',
createTaskRunner: this.getTaskRunner(),
maxAttempts: maxAttempts + 1, // Add 1 so we get an extra attempt in case of failure during a Kibana restart
timeout: queueTimeout,
maxConcurrency,
};
}
public async scheduleTask(request: KibanaRequest, params: ReportTaskParams) {
const reportingHealth = await this.opts.reporting.getHealthInfo();
const shouldScheduleWithApiKey =
reportingHealth.hasPermanentEncryptionKey && reportingHealth.isSufficientlySecure;
const taskInstance: SingleReportTaskInstance = {
taskType: REPORTING_EXECUTE_TYPE,
state: {},
params,
};
return shouldScheduleWithApiKey
? await this.getTaskManagerStart().schedule(taskInstance, { request })
: await this.getTaskManagerStart().schedule(taskInstance);
}
}

View file

@ -76,6 +76,21 @@ describe('Reporting Plugin', () => {
);
});
it('registers a saved object for scheduled reports', async () => {
plugin.setup(coreSetup, pluginSetup);
expect(coreSetup.savedObjects.registerType).toHaveBeenCalledWith(
expect.objectContaining({
name: 'scheduled_report',
namespaceType: 'multiple',
hidden: true,
indexPattern: '.kibana_alerting_cases',
management: {
importableAndExportable: false,
},
})
);
});
it('logs start issues', async () => {
// wait for the setup phase background work
plugin.setup(coreSetup, pluginSetup);
@ -168,21 +183,37 @@ describe('Reporting Plugin', () => {
});
describe('features registration', () => {
it('does not register Kibana reporting feature in traditional build flavour', async () => {
it('registers Kibana manage scheduled reporting feature in traditional build flavour', async () => {
plugin.setup(coreSetup, pluginSetup);
expect(featuresSetup.registerKibanaFeature).not.toHaveBeenCalled();
expect(featuresSetup.registerKibanaFeature).toHaveBeenCalledTimes(1);
expect(featuresSetup.registerKibanaFeature).toHaveBeenCalledWith({
id: 'manageReporting',
name: 'Manage Scheduled Reports',
description: 'View and manage scheduled reports for all users in this space.',
category: DEFAULT_APP_CATEGORIES.management,
scope: ['spaces', 'security'],
app: [],
privileges: {
all: {
api: ['manage_scheduled_reports'],
savedObject: { all: ['scheduled_report'], read: [] },
ui: ['show'],
},
read: { disabled: true, savedObject: { all: [], read: [] }, ui: [] },
},
});
expect(featuresSetup.enableReportingUiCapabilities).toHaveBeenCalledTimes(1);
});
it('registers Kibana reporting feature in serverless build flavour', async () => {
it('registers additional Kibana reporting feature in serverless build flavour', async () => {
const serverlessInitContext = coreMock.createPluginInitializerContext(configSchema);
// Force type-cast to convert `ReadOnly<PackageInfo>` to mutable `PackageInfo`.
(serverlessInitContext.env.packageInfo as PackageInfo).buildFlavor = 'serverless';
plugin = new ReportingPlugin(serverlessInitContext);
plugin.setup(coreSetup, pluginSetup);
expect(featuresSetup.registerKibanaFeature).toHaveBeenCalledTimes(1);
expect(featuresSetup.registerKibanaFeature).toHaveBeenCalledWith({
expect(featuresSetup.registerKibanaFeature).toHaveBeenCalledTimes(2);
expect(featuresSetup.registerKibanaFeature).toHaveBeenNthCalledWith(1, {
id: 'reporting',
name: 'Reporting',
category: DEFAULT_APP_CATEGORIES.management,
@ -193,6 +224,22 @@ describe('Reporting Plugin', () => {
read: { disabled: true, savedObject: { all: [], read: [] }, ui: [] },
},
});
expect(featuresSetup.registerKibanaFeature).toHaveBeenNthCalledWith(2, {
id: 'manageReporting',
name: 'Manage Scheduled Reports',
description: 'View and manage scheduled reports for all users in this space.',
category: DEFAULT_APP_CATEGORIES.management,
scope: ['spaces', 'security'],
app: [],
privileges: {
all: {
api: ['manage_scheduled_reports'],
savedObject: { all: ['scheduled_report'], read: [] },
ui: ['show'],
},
read: { disabled: true, savedObject: { all: [], read: [] }, ui: [] },
},
});
expect(featuresSetup.enableReportingUiCapabilities).toHaveBeenCalledTimes(1);
});
});

View file

@ -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);

View file

@ -0,0 +1,78 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { EcsEvent } from '@kbn/core/server';
import type { AuditEvent } from '@kbn/security-plugin/server';
import type { ArrayElement } from '@kbn/utility-types';
export enum ScheduledReportAuditAction {
SCHEDULE = 'scheduled_report_schedule',
LIST = 'scheduled_report_list',
DISABLE = 'scheduled_report_disable',
}
type VerbsTuple = [string, string, string];
const scheduledReportEventVerbs: Record<ScheduledReportAuditAction, VerbsTuple> = {
scheduled_report_schedule: ['create', 'creating', 'created'],
scheduled_report_list: ['access', 'accessing', 'accessed'],
scheduled_report_disable: ['disable', 'disabling', 'disabled'],
};
const scheduledReportEventTypes: Record<
ScheduledReportAuditAction,
ArrayElement<EcsEvent['type']>
> = {
scheduled_report_schedule: 'creation',
scheduled_report_list: 'access',
scheduled_report_disable: 'change',
};
export interface ScheduledReportAuditEventParams {
action: ScheduledReportAuditAction;
outcome?: EcsEvent['outcome'];
savedObject?: NonNullable<AuditEvent['kibana']>['saved_object'];
error?: Error;
}
export function scheduledReportAuditEvent({
action,
savedObject,
outcome,
error,
}: ScheduledReportAuditEventParams): AuditEvent {
const doc = savedObject
? [`scheduled report [id=${savedObject.id}]`, savedObject.name && `[name=${savedObject.name}]`]
.filter(Boolean)
.join(' ')
: 'a scheduled report';
const [present, progressive, past] = scheduledReportEventVerbs[action];
const message = error
? `Failed attempt to ${present} ${doc}`
: outcome === 'unknown'
? `User is ${progressive} ${doc}`
: `User has ${past} ${doc}`;
const type = scheduledReportEventTypes[action];
return {
message,
event: {
action,
category: ['database'],
type: type ? [type] : undefined,
outcome: outcome ?? (error ? 'failure' : 'success'),
},
kibana: {
saved_object: savedObject,
},
error: error && {
code: error.name,
message: error.message,
},
};
}

View file

@ -1,264 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import Boom from '@hapi/boom';
import moment from 'moment';
import { schema, TypeOf } from '@kbn/config-schema';
import type { KibanaRequest, KibanaResponseFactory, Logger } from '@kbn/core/server';
import { i18n } from '@kbn/i18n';
import { PUBLIC_ROUTES } from '@kbn/reporting-common';
import type { BaseParams } from '@kbn/reporting-common/types';
import { cryptoFactory } from '@kbn/reporting-server';
import rison from '@kbn/rison';
import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common';
import { type Counters, getCounters } from '..';
import type { ReportingCore } from '../../..';
import { checkParamsVersion } from '../../../lib';
import { Report } from '../../../lib/store';
import type {
ReportingJobResponse,
ReportingRequestHandlerContext,
ReportingUser,
} from '../../../types';
export const handleUnavailable = (res: KibanaResponseFactory) => {
return res.custom({ statusCode: 503, body: 'Not Available' });
};
const validation = {
params: schema.object({ exportType: schema.string({ minLength: 2 }) }),
body: schema.nullable(schema.object({ jobParams: schema.maybe(schema.string()) })),
query: schema.nullable(schema.object({ jobParams: schema.string({ defaultValue: '' }) })),
};
/**
* Handles the common parts of requests to generate a report
* Serves report job handling in the context of the request to generate the report
*/
export class RequestHandler {
constructor(
private reporting: ReportingCore,
private user: ReportingUser,
private context: ReportingRequestHandlerContext,
private path: string,
private req: KibanaRequest<
TypeOf<(typeof validation)['params']>,
TypeOf<(typeof validation)['query']>,
TypeOf<(typeof validation)['body']>
>,
private res: KibanaResponseFactory,
private logger: Logger
) {}
private async encryptHeaders() {
const { encryptionKey } = this.reporting.getConfig();
const crypto = cryptoFactory(encryptionKey);
return await crypto.encrypt(this.req.headers);
}
public async enqueueJob(exportTypeId: string, jobParams: BaseParams) {
const { reporting, logger, context, req, user } = this;
const exportType = reporting.getExportTypesRegistry().getById(exportTypeId);
if (exportType == null) {
throw new Error(`Export type ${exportTypeId} does not exist in the registry!`);
}
const store = await reporting.getStore();
if (!exportType.createJob) {
throw new Error(`Export type ${exportTypeId} is not a valid instance!`);
}
// 1. Ensure the incoming params have a version field (should be set by the UI)
jobParams.version = checkParamsVersion(jobParams, logger);
// 2. Encrypt request headers to store for the running report job to authenticate itself with Kibana
const headers = await this.encryptHeaders();
// 3. Create a payload object by calling exportType.createJob(), and adding some automatic parameters
const job = await exportType.createJob(jobParams, context, req);
const spaceId = reporting.getSpaceId(req, logger);
const payload = {
...job,
headers,
title: job.title,
objectType: jobParams.objectType,
browserTimezone: jobParams.browserTimezone,
version: jobParams.version,
spaceId,
};
// 4. Add the report to ReportingStore to show as pending
const report = await store.addReport(
new Report({
jobtype: exportType.jobType,
created_by: user ? user.username : false,
payload,
migration_version: jobParams.version,
space_id: spaceId || DEFAULT_SPACE_ID,
meta: {
// telemetry fields
objectType: jobParams.objectType,
layout: jobParams.layout?.id,
isDeprecated: job.isDeprecated,
},
})
);
logger.debug(`Successfully stored pending job: ${report._index}/${report._id}`);
// 5. Schedule the report with Task Manager
const task = await reporting.scheduleTask(req, report.toReportTaskJSON());
logger.info(
`Scheduled ${exportType.name} reporting task. Task ID: task:${task.id}. Report ID: ${report._id}`
);
// 6. Log the action with event log
reporting.getEventLogger(report, task).logScheduleTask();
return report;
}
public getJobParams(): BaseParams {
let jobParamsRison: null | string = null;
const req = this.req;
const res = this.res;
if (req.body) {
const { jobParams: jobParamsPayload } = req.body;
jobParamsRison = jobParamsPayload ? jobParamsPayload : null;
} else if (req.query?.jobParams) {
const { jobParams: queryJobParams } = req.query;
if (queryJobParams) {
jobParamsRison = queryJobParams;
} else {
jobParamsRison = null;
}
}
if (!jobParamsRison) {
throw res.customError({
statusCode: 400,
body: 'A jobParams RISON string is required in the querystring or POST body',
});
}
let jobParams;
try {
jobParams = rison.decode(jobParamsRison) as BaseParams | null;
if (!jobParams) {
throw res.customError({
statusCode: 400,
body: 'Missing jobParams!',
});
}
} catch (err) {
throw res.customError({
statusCode: 400,
body: `invalid rison: ${jobParamsRison}`,
});
}
return jobParams;
}
public static getValidation() {
return validation;
}
public async handleGenerateRequest(exportTypeId: string, jobParams: BaseParams) {
const req = this.req;
const reporting = this.reporting;
const counters = getCounters(
req.route.method,
this.path.replace(/{exportType}/, exportTypeId),
reporting.getUsageCounter()
);
// ensure the async dependencies are loaded
if (!this.context.reporting) {
return handleUnavailable(this.res);
}
const licenseInfo = await this.reporting.getLicenseInfo();
const licenseResults = licenseInfo[exportTypeId];
if (!licenseResults) {
return this.res.badRequest({ body: `Invalid export-type of ${exportTypeId}` });
}
if (!licenseResults.enableLinks) {
return this.res.forbidden({ body: licenseResults.message });
}
if (jobParams.browserTimezone && !moment.tz.zone(jobParams.browserTimezone)) {
return this.res.badRequest({
body: `Invalid timezone "${jobParams.browserTimezone ?? ''}".`,
});
}
let report: Report | undefined;
try {
report = await this.enqueueJob(exportTypeId, jobParams);
const { basePath } = this.reporting.getServerInfo();
const publicDownloadPath = basePath + PUBLIC_ROUTES.JOBS.DOWNLOAD_PREFIX;
// return task manager's task information and the download URL
counters.usageCounter();
const eventTracker = reporting.getEventTracker(
report._id,
exportTypeId,
jobParams.objectType
);
eventTracker?.createReport({
isDeprecated: Boolean(report.payload.isDeprecated),
isPublicApi: this.path.match(/internal/) === null,
});
return this.res.ok<ReportingJobResponse>({
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',
}),
});
}
}

View file

@ -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';

View file

@ -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<typeof getMockContext>;
let mockRequest: ReturnType<typeof getMockRequest>;
let mockResponseFactory: ReturnType<typeof getMockResponseFactory>;
let requestHandler: RequestHandler;
let requestHandler: GenerateRequestHandler;
beforeEach(async () => {
reportingCore = await createMockReportingCore(createMockConfigSchema({}));
@ -91,20 +91,23 @@ describe('Handle request to generate', () => {
mockContext = getMockContext();
mockContext.reporting = Promise.resolve({} as ReportingSetup);
requestHandler = new RequestHandler(
reportingCore,
{ username: 'testymcgee' },
mockContext,
'/api/reporting/test/generate/pdf',
mockRequest,
mockResponseFactory,
mockLogger
);
requestHandler = new GenerateRequestHandler({
reporting: reportingCore,
user: { username: 'testymcgee' },
context: mockContext,
path: '/api/reporting/test/generate/pdf',
req: mockRequest,
res: mockResponseFactory,
logger: mockLogger,
});
});
describe('Enqueue Job', () => {
test('creates a report object to queue', async () => {
const report = await requestHandler.enqueueJob('printablePdfV2', mockJobParams);
const report = await requestHandler.enqueueJob({
exportTypeId: 'printablePdfV2',
jobParams: mockJobParams,
});
const { _id, created_at: _created_at, payload, ...snapObj } = report;
expect(snapObj).toMatchInlineSnapshot(`
@ -131,6 +134,7 @@ describe('Handle request to generate', () => {
"output": null,
"process_expiration": undefined,
"queue_time_ms": undefined,
"scheduled_report_id": undefined,
"space_id": "default",
"started_at": undefined,
"status": "pending",
@ -158,7 +162,10 @@ describe('Handle request to generate', () => {
test('provides a default kibana version field for older POST URLs', async () => {
// how do we handle the printable_pdf endpoint that isn't migrating to the class instance of export types?
(mockJobParams as unknown as { version?: string }).version = undefined;
const report = await requestHandler.enqueueJob('printablePdfV2', mockJobParams);
const report = await requestHandler.enqueueJob({
exportTypeId: 'printablePdfV2',
jobParams: mockJobParams,
});
const { _id, created_at: _created_at, ...snapObj } = report;
expect(snapObj.payload.version).toBe('7.14.0');
@ -207,10 +214,14 @@ describe('Handle request to generate', () => {
});
});
describe('handleGenerateRequest', () => {
describe('handleRequest', () => {
test('disallows invalid export type', async () => {
expect(await requestHandler.handleGenerateRequest('neanderthals', mockJobParams))
.toMatchInlineSnapshot(`
expect(
await requestHandler.handleRequest({
exportTypeId: 'neanderthals',
jobParams: mockJobParams,
})
).toMatchInlineSnapshot(`
Object {
"body": "Invalid export-type of neanderthals",
}
@ -225,8 +236,12 @@ describe('Handle request to generate', () => {
},
}));
expect(await requestHandler.handleGenerateRequest('csv_searchsource', mockJobParams))
.toMatchInlineSnapshot(`
expect(
await requestHandler.handleRequest({
exportTypeId: 'csv_searchsource',
jobParams: mockJobParams,
})
).toMatchInlineSnapshot(`
Object {
"body": "seeing this means the license isn't supported",
}
@ -234,30 +249,26 @@ describe('Handle request to generate', () => {
});
test('disallows invalid browser timezone', async () => {
(reportingCore.getLicenseInfo as jest.Mock) = jest.fn(() => ({
csv_searchsource: {
enableLinks: false,
message: `seeing this means the license isn't supported`,
},
}));
expect(
await requestHandler.handleGenerateRequest('csv_searchsource', {
...mockJobParams,
browserTimezone: 'America/Amsterdam',
await requestHandler.handleRequest({
exportTypeId: 'csv_searchsource',
jobParams: {
...mockJobParams,
browserTimezone: 'America/Amsterdam',
},
})
).toMatchInlineSnapshot(`
Object {
"body": "seeing this means the license isn't supported",
"body": "Invalid timezone \\"America/Amsterdam\\".",
}
`);
});
test('generates the download path', async () => {
const { body } = (await requestHandler.handleGenerateRequest(
'csv_searchsource',
mockJobParams
)) as unknown as { body: ReportingJobResponse };
const { body } = (await requestHandler.handleRequest({
exportTypeId: 'csv_searchsource',
jobParams: mockJobParams,
})) as unknown as { body: ReportingJobResponse };
expect(body.path).toMatch('/mock-server-basepath/api/reporting/jobs/download/mock-report-id');
});

View file

@ -0,0 +1,134 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { schema } from '@kbn/config-schema';
import { PUBLIC_ROUTES } from '@kbn/reporting-common';
import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common';
import { getCounters } from '..';
import { Report, SavedReport } from '../../../lib/store';
import type { ReportingJobResponse } from '../../../types';
import { RequestHandler, RequestParams } from './request_handler';
const validation = {
params: schema.object({ exportType: schema.string({ minLength: 2 }) }),
body: schema.nullable(schema.object({ jobParams: schema.maybe(schema.string()) })),
query: schema.nullable(schema.object({ jobParams: schema.string({ defaultValue: '' }) })),
};
/**
* Handles the common parts of requests to generate a report
* Serves report job handling in the context of the request to generate the report
*/
export class GenerateRequestHandler extends RequestHandler<
(typeof validation)['params'],
(typeof validation)['query'],
(typeof validation)['body'],
SavedReport
> {
public static getValidation() {
return validation;
}
public async enqueueJob(params: RequestParams) {
const { exportTypeId, jobParams } = params;
const { reporting, logger, req, user } = this.opts;
const store = await reporting.getStore();
const { version, job, jobType, name } = await this.createJob(exportTypeId, jobParams);
const spaceId = reporting.getSpaceId(req, logger);
// Encrypt request headers to store for the running report job to authenticate itself with Kibana
const headers = await this.encryptHeaders();
const payload = {
...job,
headers,
title: job.title,
objectType: jobParams.objectType,
browserTimezone: jobParams.browserTimezone,
version,
spaceId,
};
// Add the report to ReportingStore to show as pending
const report = await store.addReport(
new Report({
jobtype: jobType,
created_by: user ? user.username : false,
payload,
migration_version: version,
space_id: spaceId || DEFAULT_SPACE_ID,
meta: {
// telemetry fields
objectType: jobParams.objectType,
layout: jobParams.layout?.id,
isDeprecated: job.isDeprecated,
},
})
);
logger.debug(`Successfully stored pending job: ${report._index}/${report._id}`);
// Schedule the report with Task Manager
const task = await reporting.scheduleTask(req, report.toReportTaskJSON());
logger.info(
`Scheduled ${name} reporting task. Task ID: task:${task.id}. Report ID: ${report._id}`
);
// Log the action with event log
reporting.getEventLogger(report, task).logScheduleTask();
return report;
}
public async handleRequest(params: RequestParams) {
const { exportTypeId, jobParams } = params;
const { reporting, req, res, path } = this.opts;
const counters = getCounters(
req.route.method,
path.replace(/{exportType}/, exportTypeId),
reporting.getUsageCounter()
);
const checkErrorResponse = await this.checkLicenseAndTimezone(
exportTypeId,
jobParams.browserTimezone
);
if (checkErrorResponse) {
return checkErrorResponse;
}
let report: Report | undefined;
try {
report = await this.enqueueJob(params);
const { basePath } = reporting.getServerInfo();
const publicDownloadPath = basePath + PUBLIC_ROUTES.JOBS.DOWNLOAD_PREFIX;
// return task manager's task information and the download URL
counters.usageCounter();
const eventTracker = reporting.getEventTracker(
report._id,
exportTypeId,
jobParams.objectType
);
eventTracker?.createReport({
isDeprecated: Boolean(report.payload.isDeprecated),
isPublicApi: path.match(/internal/) === null,
});
return res.ok<ReportingJobResponse>({
headers: { 'content-type': 'application/json' },
body: {
path: `${publicDownloadPath}/${report._id}`,
job: report.toApiJSON(),
},
});
} catch (err) {
return this.handleError(err, counters, report?.jobtype);
}
}
}

View file

@ -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';

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { transformRawScheduledReportToReport } from './transform_raw_scheduled_report';
export { transformRawScheduledReportToTaskParams } from './transform_raw_scheduled_report_to_task';

View file

@ -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<ScheduledReportType>
): 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,
};
}

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { SavedObject } from '@kbn/core/server';
import { ScheduledReportType } from '../../../../types';
import { ScheduledReportTaskParamsWithoutSpaceId } from '../../../../lib/tasks';
export function transformRawScheduledReportToTaskParams(
rawScheduledReport: SavedObject<ScheduledReportType>
): ScheduledReportTaskParamsWithoutSpaceId {
return {
id: rawScheduledReport.id,
jobtype: rawScheduledReport.attributes.jobType,
schedule: rawScheduledReport.attributes.schedule,
};
}

View file

@ -0,0 +1,208 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import Boom from '@hapi/boom';
import moment from 'moment';
import { schema, TypeOf } from '@kbn/config-schema';
import type {
IKibanaResponse,
KibanaRequest,
KibanaResponseFactory,
Logger,
} from '@kbn/core/server';
import { i18n } from '@kbn/i18n';
import type { BaseParams } from '@kbn/reporting-common/types';
import { cryptoFactory } from '@kbn/reporting-server';
import rison from '@kbn/rison';
import { RruleSchedule } from '@kbn/task-manager-plugin/server';
import { RawNotification } from '../../../saved_objects/scheduled_report/schemas/latest';
import { checkParamsVersion } from '../../../lib';
import { type Counters } from '..';
import type { ReportingCore } from '../../..';
import type { ReportingRequestHandlerContext, ReportingUser } from '../../../types';
export const handleUnavailable = (res: KibanaResponseFactory) => {
return res.custom({ statusCode: 503, body: 'Not Available' });
};
const ParamsValidation = schema.recordOf(schema.string(), schema.string());
const QueryValidation = schema.nullable(
schema.recordOf(schema.string(), schema.maybe(schema.string()))
);
const BodyValidation = schema.nullable(
schema.recordOf(schema.string(), schema.maybe(schema.any()))
);
interface ConstructorOpts<
Params extends typeof ParamsValidation,
Query extends typeof QueryValidation,
Body extends typeof BodyValidation
> {
reporting: ReportingCore;
user: ReportingUser;
context: ReportingRequestHandlerContext;
path: string;
req: KibanaRequest<TypeOf<Params>, TypeOf<Query>, TypeOf<Body>>;
res: KibanaResponseFactory;
logger: Logger;
}
export interface RequestParams {
exportTypeId: string;
jobParams: BaseParams;
id?: string;
schedule?: RruleSchedule;
notification?: RawNotification;
}
/**
* Handles the common parts of requests to generate or schedule a report
* Serves report job handling in the context of the request to generate the report
*/
export abstract class RequestHandler<
Params extends typeof ParamsValidation,
Query extends typeof QueryValidation,
Body extends typeof BodyValidation,
Output extends Record<string, any>
> {
constructor(protected readonly opts: ConstructorOpts<Params, Query, Body>) {}
public static getValidation() {
throw new Error('getValidation() must be implemented in a subclass');
}
public abstract enqueueJob(params: RequestParams): Promise<Output>;
public abstract handleRequest(params: RequestParams): Promise<IKibanaResponse>;
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<IKibanaResponse | null> {
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',
}),
});
}
}

View file

@ -0,0 +1,791 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
jest.mock('uuid', () => ({ v4: () => 'mock-report-id' }));
import rison from '@kbn/rison';
import {
AuditLogger,
FakeRawRequest,
KibanaRequest,
KibanaResponseFactory,
SavedObjectsClientContract,
} from '@kbn/core/server';
import { coreMock, httpServerMock, loggingSystemMock } from '@kbn/core/server/mocks';
import { JobParamsPDFV2 } from '@kbn/reporting-export-types-pdf-common';
import { createMockConfigSchema } from '@kbn/reporting-mocks-server';
import { ReportingCore } from '../../..';
import {
createMockPluginSetup,
createMockPluginStart,
createMockReportingCore,
} from '../../../test_helpers';
import { ReportingRequestHandlerContext, ReportingSetup } from '../../../types';
import { ScheduleRequestHandler } from './schedule_request_handler';
import { TaskStatus } from '@kbn/task-manager-plugin/server';
import { licensingMock } from '@kbn/licensing-plugin/server/mocks';
import { BehaviorSubject } from 'rxjs';
const getMockContext = () =>
({
core: coreMock.createRequestHandlerContext(),
} as unknown as ReportingRequestHandlerContext);
const getMockRequest = () =>
({
url: { port: '5601', search: '', pathname: '/foo' },
route: { path: '/foo', options: {} },
} as KibanaRequest<unknown, unknown, unknown>);
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<typeof getMockContext>;
let mockRequest: ReturnType<typeof getMockRequest>;
let mockResponseFactory: ReturnType<typeof getMockResponseFactory>;
let requestHandler: ScheduleRequestHandler;
let soClient: SavedObjectsClientContract;
let auditLogger: AuditLogger;
beforeEach(async () => {
jest.clearAllMocks();
const mockConfig = createMockConfigSchema({});
reportingCore = await createMockReportingCore(
mockConfig,
createMockPluginSetup({}),
await createMockPluginStart(
{
licensing: {
...licensingMock.createStart(),
license$: new BehaviorSubject({
isAvailable: true,
isActive: true,
type: 'platinum',
getFeature: () => true,
}),
},
},
mockConfig
)
);
mockRequest = getMockRequest();
mockResponseFactory = getMockResponseFactory();
(mockResponseFactory.ok as jest.Mock) = jest.fn((args: unknown) => args);
(mockResponseFactory.forbidden as jest.Mock) = jest.fn((args: unknown) => args);
(mockResponseFactory.badRequest as jest.Mock) = jest.fn((args: unknown) => args);
mockContext = getMockContext();
mockContext.reporting = Promise.resolve({} as ReportingSetup);
auditLogger = await reportingCore.getAuditLogger(fakeRawRequest as unknown as KibanaRequest);
auditLogger.log = jest.fn();
soClient = await reportingCore.getScopedSoClient(fakeRawRequest as unknown as KibanaRequest);
soClient.create = jest.fn().mockImplementation(async (_, opts) => {
return {
id: 'foo',
attributes: opts,
type: 'scheduled-report',
};
});
jest.spyOn(reportingCore, 'scheduleRecurringTask').mockResolvedValue({
id: 'task-id',
scheduledAt: new Date(),
attempts: 0,
status: TaskStatus.Idle,
runAt: new Date(),
startedAt: new Date(),
retryAt: new Date(),
state: {},
ownerId: 'reporting',
taskType: 'reporting:printable_pdf_v2',
params: {},
});
jest.spyOn(reportingCore, 'getHealthInfo').mockResolvedValue({
isSufficientlySecure: true,
hasPermanentEncryptionKey: true,
areNotificationsEnabled: true,
});
requestHandler = new ScheduleRequestHandler({
reporting: reportingCore,
user: { username: 'testymcgee' },
context: mockContext,
path: '/api/reporting/test/generate/pdf',
// @ts-ignore
req: mockRequest,
res: mockResponseFactory,
logger: mockLogger,
});
});
describe('enqueueJob', () => {
test('creates a scheduled_report saved object and schedules task', async () => {
const report = await requestHandler.enqueueJob({
exportTypeId: 'printablePdfV2',
jobParams: mockJobParams,
schedule: { rrule: { freq: 1, interval: 2, tzid: 'UTC' } },
});
const { id, created_at: _created_at, payload, ...snapObj } = report;
expect(snapObj).toMatchInlineSnapshot(`
Object {
"created_by": "testymcgee",
"jobtype": "printable_pdf_v2",
"meta": Object {
"isDeprecated": false,
"layout": "preserve_layout",
"objectType": "cool_object_type",
},
"migration_version": "unknown",
"notification": undefined,
"schedule": Object {
"rrule": Object {
"freq": 1,
"interval": 2,
"tzid": "UTC",
},
},
}
`);
expect(payload).toMatchInlineSnapshot(`
Object {
"browserTimezone": "UTC",
"isDeprecated": false,
"layout": Object {
"id": "preserve_layout",
},
"locatorParams": Array [],
"objectType": "cool_object_type",
"title": "cool_title",
"version": "unknown",
}
`);
expect(auditLogger.log).toHaveBeenCalledWith({
event: {
action: 'scheduled_report_schedule',
category: ['database'],
outcome: 'unknown',
type: ['creation'],
},
kibana: {
saved_object: { id: 'mock-report-id', name: 'cool_title', type: 'scheduled_report' },
},
message: 'User is creating scheduled report [id=mock-report-id] [name=cool_title]',
});
expect(soClient.create).toHaveBeenCalledWith(
'scheduled_report',
{
jobType: 'printable_pdf_v2',
createdAt: expect.any(String),
createdBy: 'testymcgee',
title: 'cool_title',
enabled: true,
payload: JSON.stringify(payload),
schedule: {
rrule: {
freq: 1,
interval: 2,
tzid: 'UTC',
},
},
migrationVersion: 'unknown',
meta: {
objectType: 'cool_object_type',
layout: 'preserve_layout',
isDeprecated: false,
},
},
{ id: 'mock-report-id' }
);
expect(reportingCore.scheduleRecurringTask).toHaveBeenCalledWith(mockRequest, {
id: 'foo',
jobtype: 'printable_pdf_v2',
schedule: { rrule: { freq: 1, interval: 2, tzid: 'UTC' } },
});
});
test('creates a scheduled_report saved object with notification', async () => {
const report = await requestHandler.enqueueJob({
exportTypeId: 'printablePdfV2',
jobParams: mockJobParams,
schedule: { rrule: { freq: 1, interval: 2, tzid: 'UTC' } },
notification: { email: { to: ['a@b.com'] } },
});
const { id, created_at: _created_at, payload, ...snapObj } = report;
expect(snapObj).toMatchInlineSnapshot(`
Object {
"created_by": "testymcgee",
"jobtype": "printable_pdf_v2",
"meta": Object {
"isDeprecated": false,
"layout": "preserve_layout",
"objectType": "cool_object_type",
},
"migration_version": "unknown",
"notification": Object {
"email": Object {
"to": Array [
"a@b.com",
],
},
},
"schedule": Object {
"rrule": Object {
"freq": 1,
"interval": 2,
"tzid": "UTC",
},
},
}
`);
expect(payload).toMatchInlineSnapshot(`
Object {
"browserTimezone": "UTC",
"isDeprecated": false,
"layout": Object {
"id": "preserve_layout",
},
"locatorParams": Array [],
"objectType": "cool_object_type",
"title": "cool_title",
"version": "unknown",
}
`);
expect(auditLogger.log).toHaveBeenCalledWith({
event: {
action: 'scheduled_report_schedule',
category: ['database'],
outcome: 'unknown',
type: ['creation'],
},
kibana: {
saved_object: { id: 'mock-report-id', name: 'cool_title', type: 'scheduled_report' },
},
message: 'User is creating scheduled report [id=mock-report-id] [name=cool_title]',
});
expect(soClient.create).toHaveBeenCalledWith(
'scheduled_report',
{
jobType: 'printable_pdf_v2',
createdAt: expect.any(String),
createdBy: 'testymcgee',
title: 'cool_title',
enabled: true,
payload: JSON.stringify(payload),
schedule: {
rrule: {
freq: 1,
interval: 2,
tzid: 'UTC',
},
},
migrationVersion: 'unknown',
meta: {
objectType: 'cool_object_type',
layout: 'preserve_layout',
isDeprecated: false,
},
notification: { email: { to: ['a@b.com'] } },
},
{ id: 'mock-report-id' }
);
});
test('throws errors from so client create', async () => {
soClient.create = jest.fn().mockImplementationOnce(async () => {
throw new Error('SO create error');
});
await expect(
requestHandler.enqueueJob({
exportTypeId: 'printablePdfV2',
jobParams: mockJobParams,
schedule: { rrule: { freq: 1, interval: 2, tzid: 'UTC' } },
})
).rejects.toThrowErrorMatchingInlineSnapshot(`"SO create error"`);
});
});
describe('getJobParams', () => {
test('parse jobParams from body', () => {
// @ts-ignore body is a read-only property
mockRequest.body = { jobParams: rison.encode(mockJobParams) };
expect(requestHandler.getJobParams()).toEqual(mockJobParams);
});
test('handles missing job params', () => {
let error: { statusCode: number; body: string } | undefined;
try {
requestHandler.getJobParams();
} catch (err) {
error = err;
}
expect(error?.statusCode).toBe(400);
});
test('handles null job params', () => {
let error: { statusCode: number; body: string } | undefined;
try {
// @ts-ignore body is a read-only property
mockRequest.body = { jobParams: rison.encode(null) };
requestHandler.getJobParams();
} catch (err) {
error = err;
}
expect(error?.statusCode).toBe(400);
});
test('handles invalid rison', () => {
let error: { statusCode: number; body: string } | undefined;
// @ts-ignore body is a read-only property
mockRequest.body = { jobParams: mockJobParams };
try {
requestHandler.getJobParams();
} catch (err) {
error = err;
}
expect(error?.statusCode).toBe(400);
});
});
describe('getSchedule', () => {
test('parse schedule from body', () => {
// @ts-ignore body is a read-only property
mockRequest.body = {
jobParams: rison.encode(mockJobParams),
schedule: { rrule: { freq: 1, interval: 2 } },
};
expect(requestHandler.getSchedule()).toEqual({ rrule: { freq: 1, interval: 2 } });
});
test('handles missing schedule', () => {
let error: { statusCode: number; body: string } | undefined;
try {
// @ts-ignore body is a read-only property
mockRequest.body = {
jobParams: rison.encode(mockJobParams),
};
requestHandler.getSchedule();
} catch (err) {
error = err;
}
expect(error?.statusCode).toBe(400);
expect(error?.body).toBe('A schedule is required to create a scheduled report.');
});
test('handles null schedule', () => {
let error: { statusCode: number; body: string } | undefined;
try {
// @ts-ignore body is a read-only property
mockRequest.body = {
jobParams: rison.encode(mockJobParams),
schedule: null,
};
requestHandler.getSchedule();
} catch (err) {
error = err;
}
expect(error?.statusCode).toBe(400);
expect(error?.body).toBe('A schedule is required to create a scheduled report.');
});
test('handles empty schedule', () => {
let error: { statusCode: number; body: string } | undefined;
try {
// @ts-ignore body is a read-only property
mockRequest.body = {
jobParams: rison.encode(mockJobParams),
schedule: {},
};
requestHandler.getSchedule();
} catch (err) {
error = err;
}
expect(error?.statusCode).toBe(400);
expect(error?.body).toBe('A schedule is required to create a scheduled report.');
});
test('handles null rrule schedule', () => {
let error: { statusCode: number; body: string } | undefined;
try {
// @ts-ignore body is a read-only property
mockRequest.body = {
jobParams: rison.encode(mockJobParams),
schedule: { rrule: null },
};
requestHandler.getSchedule();
} catch (err) {
error = err;
}
expect(error?.statusCode).toBe(400);
expect(error?.body).toBe('A schedule is required to create a scheduled report.');
});
test('handles empty rrule schedule', () => {
let error: { statusCode: number; body: string } | undefined;
try {
// @ts-ignore body is a read-only property
mockRequest.body = {
jobParams: rison.encode(mockJobParams),
schedule: { rrule: {} },
};
requestHandler.getSchedule();
} catch (err) {
error = err;
}
expect(error?.statusCode).toBe(400);
expect(error?.body).toBe('A schedule is required to create a scheduled report.');
});
});
describe('getNotification', () => {
test('parse notification from body', () => {
// @ts-ignore body is a read-only property
mockRequest.body = {
jobParams: rison.encode(mockJobParams),
schedule: { rrule: { freq: 1, interval: 2 } },
notification: { email: { to: ['a@b.com'] } },
};
expect(requestHandler.getNotification()).toEqual({ email: { to: ['a@b.com'] } });
});
test('parse notification from body when no to defined', () => {
// @ts-ignore body is a read-only property
mockRequest.body = {
jobParams: rison.encode(mockJobParams),
schedule: { rrule: { freq: 1, interval: 2 } },
notification: { email: { bcc: ['a@b.com'] } },
};
expect(requestHandler.getNotification()).toEqual({ email: { bcc: ['a@b.com'] } });
});
test('returns undefined if notification object is empty', () => {
// @ts-ignore body is a read-only property
mockRequest.body = {
jobParams: rison.encode(mockJobParams),
schedule: { rrule: { freq: 1, interval: 2 } },
notification: {},
};
expect(requestHandler.getNotification()).toBeUndefined();
});
test('returns undefined if notification object is null', () => {
// @ts-ignore body is a read-only property
mockRequest.body = {
jobParams: rison.encode(mockJobParams),
schedule: { rrule: { freq: 1, interval: 2 } },
notification: null,
};
expect(requestHandler.getNotification()).toBeUndefined();
});
test('returns undefined if notification.email object is empty', () => {
// @ts-ignore body is a read-only property
mockRequest.body = {
jobParams: rison.encode(mockJobParams),
schedule: { rrule: { freq: 1, interval: 2 } },
notification: { email: {} },
};
expect(requestHandler.getNotification()).toBeUndefined();
});
test('returns undefined if notification.email arrays are all empty', () => {
// @ts-ignore body is a read-only property
mockRequest.body = {
jobParams: rison.encode(mockJobParams),
schedule: { rrule: { freq: 1, interval: 2 } },
notification: { email: { to: [], cc: [], bcc: [] } },
};
expect(requestHandler.getNotification()).toBeUndefined();
});
test('returns undefined if notification.email object is null', () => {
// @ts-ignore body is a read-only property
mockRequest.body = {
jobParams: rison.encode(mockJobParams),
schedule: { rrule: { freq: 1, interval: 2 } },
notification: { email: null },
};
expect(requestHandler.getNotification()).toBeUndefined();
});
test('handles invalid email address', () => {
jest
.spyOn(reportingCore, 'validateNotificationEmails')
.mockReturnValueOnce('not valid emails: foo');
let error: { statusCode: number; body: string } | undefined;
try {
// @ts-ignore body is a read-only property
mockRequest.body = {
jobParams: rison.encode(mockJobParams),
schedule: { rrule: { freq: 1, interval: 2 } },
notification: { email: { to: ['foo'] } },
};
requestHandler.getNotification();
} catch (err) {
error = err;
}
expect(error?.statusCode).toBe(400);
expect(error?.body).toBe('Invalid email address(es): not valid emails: foo');
});
test('handles too many recipients', () => {
let error: { statusCode: number; body: string } | undefined;
try {
// @ts-ignore body is a read-only property
mockRequest.body = {
jobParams: rison.encode(mockJobParams),
schedule: { rrule: { freq: 1, interval: 2 } },
notification: {
email: {
to: [
'1@elastic.co',
'2@elastic.co',
'3@elastic.co',
'4@elastic.co',
'5@elastic.co',
'6@elastic.co',
'7@elastic.co',
],
cc: [
'8@elastic.co',
'9@elastic.co',
'10@elastic.co',
'11@elastic.co',
'12@elastic.co',
'13@elastic.co',
'14@elastic.co',
'15@elastic.co',
'16@elastic.co',
'17@elastic.co',
],
bcc: [
'18@elastic.co',
'19@elastic.co',
'20@elastic.co',
'21@elastic.co',
'22@elastic.co',
'23@elastic.co',
'24@elastic.co',
'25@elastic.co',
'26@elastic.co',
'27@elastic.co',
'28@elastic.co',
'29@elastic.co',
'30@elastic.co',
'31@elastic.co',
],
},
},
};
requestHandler.getNotification();
} catch (err) {
error = err;
}
expect(error?.statusCode).toBe(400);
expect(error?.body).toBe(
'Maximum number of recipients exceeded: cannot specify more than 30 recipients.'
);
});
});
describe('handleRequest', () => {
test('disallows invalid export type', async () => {
expect(
await requestHandler.handleRequest({
exportTypeId: 'neanderthals',
jobParams: mockJobParams,
})
).toMatchInlineSnapshot(`
Object {
"body": "Invalid export-type of neanderthals",
}
`);
});
test('disallows unsupporting license', async () => {
(reportingCore.getLicenseInfo as jest.Mock) = jest.fn(() => ({
scheduledReports: {
enableLinks: false,
message: `seeing this means the license isn't supported`,
},
}));
expect(
await requestHandler.handleRequest({
exportTypeId: 'csv_searchsource',
jobParams: mockJobParams,
})
).toMatchInlineSnapshot(`
Object {
"body": "seeing this means the license isn't supported",
}
`);
});
test('disallows invalid browser timezone', async () => {
expect(
await requestHandler.handleRequest({
exportTypeId: 'csv_searchsource',
jobParams: {
...mockJobParams,
browserTimezone: 'America/Amsterdam',
},
})
).toMatchInlineSnapshot(`
Object {
"body": "Invalid timezone \\"America/Amsterdam\\".",
}
`);
});
test('disallows scheduling when user is "false"', async () => {
requestHandler = new ScheduleRequestHandler({
reporting: reportingCore,
user: false,
context: mockContext,
path: '/api/reporting/test/generate/pdf',
// @ts-ignore
req: mockRequest,
res: mockResponseFactory,
logger: mockLogger,
});
expect(
await requestHandler.handleRequest({
exportTypeId: 'csv_searchsource',
jobParams: mockJobParams,
})
).toMatchInlineSnapshot(`
Object {
"body": "User must be authenticated to schedule a report",
}
`);
});
test('disallows scheduling when reportingHealth.hasPermanentEncryptionKey = false', async () => {
jest.spyOn(reportingCore, 'getHealthInfo').mockResolvedValueOnce({
isSufficientlySecure: true,
hasPermanentEncryptionKey: false,
areNotificationsEnabled: true,
});
expect(
await requestHandler.handleRequest({
exportTypeId: 'csv_searchsource',
jobParams: mockJobParams,
})
).toMatchInlineSnapshot(`
Object {
"body": "Permanent encryption key must be set for scheduled reporting",
}
`);
});
test('disallows scheduling when reportingHealth.isSufficientlySecure=false', async () => {
jest.spyOn(reportingCore, 'getHealthInfo').mockResolvedValueOnce({
isSufficientlySecure: false,
hasPermanentEncryptionKey: true,
areNotificationsEnabled: true,
});
expect(
await requestHandler.handleRequest({
exportTypeId: 'csv_searchsource',
jobParams: mockJobParams,
})
).toMatchInlineSnapshot(`
Object {
"body": "Security and API keys must be enabled for scheduled reporting",
}
`);
});
test('handles errors from so client create', async () => {
soClient.create = jest.fn().mockImplementationOnce(async () => {
throw new Error('SO create error');
});
expect(
await requestHandler.handleRequest({
exportTypeId: 'csv_searchsource',
jobParams: mockJobParams,
})
).toMatchInlineSnapshot(`
Object {
"body": "SO create error",
"statusCode": 500,
}
`);
expect(auditLogger.log).toHaveBeenCalledWith({
error: {
code: 'Error',
message: 'SO create error',
},
event: {
action: 'scheduled_report_schedule',
category: ['database'],
outcome: 'failure',
type: ['creation'],
},
kibana: {
saved_object: {
id: 'mock-report-id',
type: 'scheduled_report',
name: 'cool_title',
},
},
message: 'Failed attempt to create scheduled report [id=mock-report-id] [name=cool_title]',
});
});
});
});

View file

@ -0,0 +1,249 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import moment from 'moment';
import { schema } from '@kbn/config-schema';
import { isEmpty, omit } from 'lodash';
import { RruleSchedule, scheduleRruleSchema } from '@kbn/task-manager-plugin/server';
import { SavedObjectsUtils } from '@kbn/core/server';
import { IKibanaResponse } from '@kbn/core/server';
import { RawNotification } from '../../../saved_objects/scheduled_report/schemas/latest';
import { rawNotificationSchema } from '../../../saved_objects/scheduled_report/schemas/v1';
import {
ScheduledReportApiJSON,
ScheduledReportType,
ScheduledReportingJobResponse,
} from '../../../types';
import { SCHEDULED_REPORT_SAVED_OBJECT_TYPE } from '../../../saved_objects';
import { RequestHandler, RequestParams } from './request_handler';
import {
transformRawScheduledReportToReport,
transformRawScheduledReportToTaskParams,
} from './lib';
import { ScheduledReportAuditAction, scheduledReportAuditEvent } from '../audit_events';
// Using the limit specified in the cloud email service limits
// https://www.elastic.co/docs/explore-analyze/alerts-cases/watcher/enable-watcher#cloud-email-service-limits
const MAX_ALLOWED_EMAILS = 30;
const validation = {
params: schema.object({ exportType: schema.string({ minLength: 2 }) }),
body: schema.object({
schedule: scheduleRruleSchema,
notification: schema.maybe(rawNotificationSchema),
jobParams: schema.string(),
}),
query: schema.nullable(schema.object({})),
};
/**
* Handles the common parts of requests to generate a report
* Serves report job handling in the context of the request to generate the report
*/
export class ScheduleRequestHandler extends RequestHandler<
(typeof validation)['params'],
(typeof validation)['query'],
(typeof validation)['body'],
ScheduledReportApiJSON
> {
protected async checkLicenseAndTimezone(
exportTypeId: string,
browserTimezone: string
): Promise<IKibanaResponse | null> {
const { reporting, res } = this.opts;
const licenseInfo = await reporting.getLicenseInfo();
const licenseResults = licenseInfo.scheduledReports;
if (!licenseResults.enableLinks) {
return res.forbidden({ body: licenseResults.message });
}
return super.checkLicenseAndTimezone(exportTypeId, browserTimezone);
}
public static getValidation() {
return validation;
}
public getSchedule(): RruleSchedule {
let rruleDef: null | RruleSchedule['rrule'] = null;
const req = this.opts.req;
const res = this.opts.res;
const { schedule } = req.body;
const { rrule } = schedule ?? {};
rruleDef = rrule;
if (isEmpty(rruleDef)) {
throw res.customError({
statusCode: 400,
body: 'A schedule is required to create a scheduled report.',
});
}
return schedule;
}
public getNotification(): RawNotification | undefined {
const { reporting, req, res } = this.opts;
const { notification } = req.body;
if (isEmpty(notification) || isEmpty(notification.email)) {
return undefined;
}
const allEmails = new Set([
...(notification.email.to || []),
...(notification.email.bcc || []),
...(notification.email.cc || []),
]);
if (allEmails.size === 0) {
return undefined;
}
if (allEmails.size > MAX_ALLOWED_EMAILS) {
throw res.customError({
statusCode: 400,
body: `Maximum number of recipients exceeded: cannot specify more than ${MAX_ALLOWED_EMAILS} recipients.`,
});
}
const invalidEmails = reporting.validateNotificationEmails([...allEmails]);
if (invalidEmails) {
throw res.customError({
statusCode: 400,
body: `Invalid email address(es): ${invalidEmails}`,
});
}
return notification;
}
public async enqueueJob(params: RequestParams) {
const { id, exportTypeId, jobParams, schedule, notification } = params;
const { reporting, logger, req, user } = this.opts;
const soClient = await reporting.getScopedSoClient(req);
const auditLogger = await reporting.getAuditLogger(req);
const { version, job, jobType, name } = await this.createJob(exportTypeId, jobParams);
const reportId = id || SavedObjectsUtils.generateId();
auditLogger.log(
scheduledReportAuditEvent({
action: ScheduledReportAuditAction.SCHEDULE,
savedObject: { type: SCHEDULED_REPORT_SAVED_OBJECT_TYPE, id: reportId, name: job.title },
outcome: 'unknown',
})
);
const payload = {
...job,
title: job.title,
objectType: jobParams.objectType,
browserTimezone: jobParams.browserTimezone,
version,
spaceId: reporting.getSpaceId(req, logger),
};
// TODO - extract saved object references before persisting
const attributes = {
createdAt: moment.utc().toISOString(),
// we've already checked that user exists in handleRequest
// this fallback is just to satisfy the type
createdBy: user ? user.username : 'unknown',
enabled: true,
jobType,
meta: {
// telemetry fields
isDeprecated: job.isDeprecated,
layout: jobParams.layout?.id,
objectType: jobParams.objectType,
},
migrationVersion: version,
...(notification ? { notification } : {}),
title: job.title,
payload: JSON.stringify(omit(payload, 'forceNow')),
schedule: schedule!,
};
// Create a scheduled_report saved object
const report = await soClient.create<ScheduledReportType>(
SCHEDULED_REPORT_SAVED_OBJECT_TYPE,
attributes,
{ id: reportId }
);
logger.debug(`Successfully created scheduled report: ${report.id}`);
// Schedule the report with Task Manager
const task = await reporting.scheduleRecurringTask(
req,
transformRawScheduledReportToTaskParams(report)
);
logger.info(
`Scheduled "${name}" reporting task. Task ID: task:${task.id}. Report ID: ${report.id}`
);
return transformRawScheduledReportToReport(report);
}
public async handleRequest(params: RequestParams) {
const { exportTypeId, jobParams } = params;
const { reporting, req, res } = this.opts;
const checkErrorResponse = await this.checkLicenseAndTimezone(
exportTypeId,
jobParams.browserTimezone
);
if (checkErrorResponse) {
return checkErrorResponse;
}
// check that security requirements are met
const reportingHealth = await reporting.getHealthInfo();
if (!reportingHealth.hasPermanentEncryptionKey) {
return res.forbidden({
body: `Permanent encryption key must be set for scheduled reporting`,
});
}
if (!reportingHealth.isSufficientlySecure) {
return res.forbidden({
body: `Security and API keys must be enabled for scheduled reporting`,
});
}
// check that username exists
if (!this.opts.user || !this.opts.user.username) {
return res.forbidden({
body: `User must be authenticated to schedule a report`,
});
}
const auditLogger = await reporting.getAuditLogger(req);
let report: ScheduledReportApiJSON | undefined;
const id = SavedObjectsUtils.generateId();
try {
report = await this.enqueueJob({ ...params, id });
return res.ok<ScheduledReportingJobResponse>({
headers: { 'content-type': 'application/json' },
body: {
job: report,
},
});
} catch (err) {
auditLogger.log(
scheduledReportAuditEvent({
action: ScheduledReportAuditAction.SCHEDULE,
savedObject: { type: SCHEDULED_REPORT_SAVED_OBJECT_TYPE, id, name: jobParams.title },
error: err,
})
);
return this.handleError(err, undefined, report?.jobtype);
}
}
}

View file

@ -5,4 +5,4 @@
* 2.0.
*/
export { handleUnavailable, RequestHandler } from './request_handler';
export { scheduledQueryFactory } from './scheduled_query';

View file

@ -0,0 +1,362 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type {
KibanaRequest,
KibanaResponseFactory,
SavedObject,
SavedObjectsFindResponse,
SavedObjectsFindResult,
} from '@kbn/core/server';
import type { Logger } from '@kbn/core/server';
import { REPORTING_DATA_STREAM_WILDCARD_WITH_LEGACY } from '@kbn/reporting-server';
import { SearchResponse } from '@elastic/elasticsearch/lib/api/types';
import { RRule } from '@kbn/rrule';
import { DEFAULT_SPACE_ID } from '@kbn/spaces-utils';
import { ReportApiJSON } from '@kbn/reporting-common/types';
import type { ReportingCore } from '../../..';
import type {
ListScheduledReportApiJSON,
ReportingUser,
ScheduledReportType,
} from '../../../types';
import { SCHEDULED_REPORT_SAVED_OBJECT_TYPE } from '../../../saved_objects';
import { ScheduledReportAuditAction, scheduledReportAuditEvent } from '../audit_events';
export const MAX_SCHEDULED_REPORT_LIST_SIZE = 100;
export const DEFAULT_SCHEDULED_REPORT_LIST_SIZE = 10;
const SCHEDULED_REPORT_ID_FIELD = 'scheduled_report_id';
const CREATED_AT_FIELD = 'created_at';
const getUsername = (user: ReportingUser) => (user ? user.username : false);
interface ApiResponse {
page: number;
per_page: number;
total: number;
data: ListScheduledReportApiJSON[];
}
const getEmptyApiResponse = (page: number, perPage: number) => ({
page,
per_page: perPage,
total: 0,
data: [],
});
interface BulkOperationError {
message: string;
status?: number;
id: string;
}
interface BulkDisableResult {
scheduled_report_ids: string[];
errors: BulkOperationError[];
total: number;
}
export type CreatedAtSearchResponse = SearchResponse<{ created_at: string }>;
export function transformSingleResponse(
logger: Logger,
so: SavedObjectsFindResult<ScheduledReportType>,
lastResponse?: CreatedAtSearchResponse
) {
const id = so.id;
const lastRunForId = (lastResponse?.hits.hits ?? []).find(
(hit) => hit.fields?.[SCHEDULED_REPORT_ID_FIELD]?.[0] === id
);
const schedule = so.attributes.schedule;
const _rrule = new RRule({
...schedule.rrule,
dtstart: new Date(),
});
let payload: ReportApiJSON['payload'] | undefined;
try {
payload = JSON.parse(so.attributes.payload);
} catch (e) {
logger.warn(`Failed to parse payload for scheduled report ${id}: ${e.message}`);
}
return {
id,
created_at: so.attributes.createdAt,
created_by: so.attributes.createdBy,
enabled: so.attributes.enabled,
jobtype: so.attributes.jobType,
last_run: lastRunForId?._source?.[CREATED_AT_FIELD],
next_run: _rrule.after(new Date())?.toISOString(),
notification: so.attributes.notification,
payload,
schedule: so.attributes.schedule,
space_id: so.namespaces?.[0] ?? DEFAULT_SPACE_ID,
title: so.attributes.title,
};
}
export function transformResponse(
logger: Logger,
result: SavedObjectsFindResponse<ScheduledReportType>,
lastResponse?: CreatedAtSearchResponse
): ApiResponse {
return {
page: result.page,
per_page: result.per_page,
total: result.total,
data: result.saved_objects.map((so) => transformSingleResponse(logger, so, lastResponse)),
};
}
export interface ScheduledQueryFactory {
list(
logger: Logger,
req: KibanaRequest,
res: KibanaResponseFactory,
user: ReportingUser,
page: number,
size: number
): Promise<ApiResponse>;
bulkDisable(
logger: Logger,
req: KibanaRequest,
res: KibanaResponseFactory,
ids: string[],
user: ReportingUser
): Promise<BulkDisableResult>;
}
export function scheduledQueryFactory(reportingCore: ReportingCore): ScheduledQueryFactory {
return {
async list(logger, req, res, user, page = 1, size = DEFAULT_SCHEDULED_REPORT_LIST_SIZE) {
try {
const esClient = await reportingCore.getEsClient();
const auditLogger = await reportingCore.getAuditLogger(req);
const savedObjectsClient = await reportingCore.getScopedSoClient(req);
const username = getUsername(user);
// if user has Manage Reporting privileges, we can list
// scheduled reports for all users in this space, otherwise
// we will filter only to the scheduled reports created by the user
const canManageReporting = await reportingCore.canManageReportingForSpace(req);
const response = await savedObjectsClient.find<ScheduledReportType>({
type: SCHEDULED_REPORT_SAVED_OBJECT_TYPE,
page,
perPage: size,
...(!canManageReporting
? { filter: `scheduled_report.attributes.createdBy: "${username}"` }
: {}),
});
if (!response) {
return getEmptyApiResponse(page, size);
}
const scheduledReportIdsAndName = response?.saved_objects.map((so) => ({
id: so.id,
name: so.attributes.title,
}));
if (!scheduledReportIdsAndName || scheduledReportIdsAndName.length === 0) {
return getEmptyApiResponse(page, size);
}
scheduledReportIdsAndName.forEach(({ id, name }) =>
auditLogger.log(
scheduledReportAuditEvent({
action: ScheduledReportAuditAction.LIST,
savedObject: {
type: SCHEDULED_REPORT_SAVED_OBJECT_TYPE,
id,
name,
},
})
)
);
let lastRunResponse;
try {
lastRunResponse = (await esClient.asInternalUser.search({
index: REPORTING_DATA_STREAM_WILDCARD_WITH_LEGACY,
size,
_source: [CREATED_AT_FIELD],
sort: [{ [CREATED_AT_FIELD]: { order: 'desc' } }],
query: {
bool: {
filter: [
{
terms: {
[SCHEDULED_REPORT_ID_FIELD]: scheduledReportIdsAndName.map(({ id }) => id),
},
},
],
},
},
collapse: { field: SCHEDULED_REPORT_ID_FIELD },
})) as CreatedAtSearchResponse;
} catch (error) {
// if no scheduled reports have run yet, we will get an error from the collapse query
// ignore these and return an empty last run
logger.warn(`Error getting last run for scheduled reports: ${error.message}`);
}
return transformResponse(logger, response, lastRunResponse);
} catch (error) {
throw res.customError({
statusCode: 500,
body: `Error listing scheduled reports: ${error.message}`,
});
}
},
async bulkDisable(logger, req, res, ids, user) {
try {
const savedObjectsClient = await reportingCore.getScopedSoClient(req);
const taskManager = await reportingCore.getTaskManager();
const auditLogger = await reportingCore.getAuditLogger(req);
const bulkErrors: BulkOperationError[] = [];
const disabledScheduledReportIds: Set<string> = new Set();
let taskIdsToDisable: string[] = [];
const username = getUsername(user);
// if user has Manage Reporting privileges, they can disable
// scheduled reports for all users in this space
const canManageReporting = await reportingCore.canManageReportingForSpace(req);
const bulkGetResult = await savedObjectsClient.bulkGet<ScheduledReportType>(
ids.map((id) => ({ id, type: SCHEDULED_REPORT_SAVED_OBJECT_TYPE }))
);
const scheduledReportSavedObjectsToUpdate: Array<SavedObject<ScheduledReportType>> = [];
for (const so of bulkGetResult.saved_objects) {
if (so.error) {
bulkErrors.push({
message: so.error.message,
status: so.error.statusCode,
id: so.id,
});
} else {
// check if user is allowed to update this scheduled report
if (so.attributes.createdBy !== username && !canManageReporting) {
bulkErrors.push({
message: `Not found.`,
status: 404,
id: so.id,
});
logger.warn(
`User "${username}" attempted to disable scheduled report "${so.id}" created by "${so.attributes.createdBy}" without sufficient privileges.`
);
auditLogger.log(
scheduledReportAuditEvent({
action: ScheduledReportAuditAction.DISABLE,
savedObject: {
type: SCHEDULED_REPORT_SAVED_OBJECT_TYPE,
id: so.id,
name: so?.attributes?.title,
},
error: new Error(`Not found.`),
})
);
} else if (so.attributes.enabled === false) {
logger.debug(`Scheduled report ${so.id} is already disabled`);
disabledScheduledReportIds.add(so.id);
} else {
auditLogger.log(
scheduledReportAuditEvent({
action: ScheduledReportAuditAction.DISABLE,
savedObject: {
type: SCHEDULED_REPORT_SAVED_OBJECT_TYPE,
id: so.id,
name: so.attributes.title,
},
outcome: 'unknown',
})
);
scheduledReportSavedObjectsToUpdate.push(so);
}
}
}
// nothing to update, return early
if (scheduledReportSavedObjectsToUpdate.length > 0) {
const bulkUpdateResult = await savedObjectsClient.bulkUpdate<ScheduledReportType>(
scheduledReportSavedObjectsToUpdate.map((so) => ({
id: so.id,
type: so.type,
attributes: {
enabled: false,
},
}))
);
for (const so of bulkUpdateResult.saved_objects) {
if (so.error) {
bulkErrors.push({
message: so.error.message,
status: so.error.statusCode,
id: so.id,
});
auditLogger.log(
scheduledReportAuditEvent({
action: ScheduledReportAuditAction.DISABLE,
savedObject: {
type: SCHEDULED_REPORT_SAVED_OBJECT_TYPE,
id: so.id,
name: so?.attributes?.title,
},
error: new Error(so.error.message),
})
);
} else {
taskIdsToDisable.push(so.id);
}
}
} else {
return {
scheduled_report_ids: [...disabledScheduledReportIds],
errors: bulkErrors,
total: disabledScheduledReportIds.size + bulkErrors.length,
};
}
// it's possible that the scheduled_report saved object was disabled but
// task disabling failed so add the list of already disabled IDs
// task manager filters out disabled tasks so this will not cause extra load
taskIdsToDisable = taskIdsToDisable.concat([...disabledScheduledReportIds]);
const resultFromDisablingTasks = await taskManager.bulkDisable(taskIdsToDisable);
for (const error of resultFromDisablingTasks.errors) {
bulkErrors.push({
message: `Scheduled report disabled but task disabling failed due to: ${error.error.message}`,
status: error.error.statusCode,
id: error.id,
});
}
for (const result of resultFromDisablingTasks.tasks) {
disabledScheduledReportIds.add(result.id);
}
return {
scheduled_report_ids: [...disabledScheduledReportIds],
errors: bulkErrors,
total: disabledScheduledReportIds.size + bulkErrors.length,
};
} catch (error) {
throw res.customError({
statusCode: 500,
body: `Error disabling scheduled reports: ${error.message}`,
});
}
},
};
}

View file

@ -11,7 +11,9 @@ import { registerDeprecationsRoutes } from './internal/deprecations/deprecations
import { registerDiagnosticRoutes } from './internal/diagnostic';
import { registerHealthRoute } from './internal/health';
import { registerGenerationRoutesInternal } from './internal/generate/generate_from_jobparams';
import { registerScheduleRoutesInternal } from './internal/schedule/schedule_from_jobparams';
import { registerJobInfoRoutesInternal } from './internal/management/jobs';
import { registerScheduledRoutesInternal } from './internal/management/scheduled';
import { registerGenerationRoutesPublic } from './public/generate_from_jobparams';
import { registerJobInfoRoutesPublic } from './public/jobs';
@ -20,7 +22,9 @@ export function registerRoutes(reporting: ReportingCore, logger: Logger) {
registerHealthRoute(reporting, logger);
registerDiagnosticRoutes(reporting, logger);
registerGenerationRoutesInternal(reporting, logger);
registerScheduleRoutesInternal(reporting, logger);
registerJobInfoRoutesInternal(reporting);
registerScheduledRoutesInternal(reporting, logger);
registerGenerationRoutesPublic(reporting, logger);
registerJobInfoRoutesPublic(reporting);
}

View file

@ -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;

View file

@ -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,

View file

@ -0,0 +1,142 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { schema } from '@kbn/config-schema';
import type { Logger } from '@kbn/core/server';
import { INTERNAL_ROUTES } from '@kbn/reporting-common';
import { KibanaResponse } from '@kbn/core-http-router-server-internal';
import { ReportingCore } from '../../..';
import { authorizedUserPreRouting, getCounters } from '../../common';
import { handleUnavailable } from '../../common/request_handler';
import { scheduledQueryFactory } from '../../common/scheduled';
import {
DEFAULT_SCHEDULED_REPORT_LIST_SIZE,
MAX_SCHEDULED_REPORT_LIST_SIZE,
} from '../../common/scheduled/scheduled_query';
const { SCHEDULED } = INTERNAL_ROUTES;
export function registerScheduledRoutesInternal(reporting: ReportingCore, logger: Logger) {
const setupDeps = reporting.getPluginSetupDeps();
const { router } = setupDeps;
const scheduledQuery = scheduledQueryFactory(reporting);
const registerInternalGetList = () => {
// list scheduled jobs in the queue, paginated
const path = SCHEDULED.LIST;
router.get(
{
path,
security: {
authz: {
enabled: false,
reason:
'This route is opted out from authorization because reporting uses its own authorization model.',
},
},
validate: {
query: schema.object({
page: schema.string({ defaultValue: '1' }),
size: schema.string({
defaultValue: `${DEFAULT_SCHEDULED_REPORT_LIST_SIZE}`,
validate: (value: string) => {
try {
const size = parseInt(value, 10);
if (size < 1 || size > MAX_SCHEDULED_REPORT_LIST_SIZE) {
return `size must be between 1 and ${MAX_SCHEDULED_REPORT_LIST_SIZE}: size: ${value}`;
}
} catch (e) {
return `size must be an integer: size: ${value}`;
}
},
}),
}),
},
options: { access: 'internal' },
},
authorizedUserPreRouting(reporting, async (user, context, req, res) => {
try {
const counters = getCounters(req.route.method, path, reporting.getUsageCounter());
// ensure the async dependencies are loaded
if (!context.reporting) {
return handleUnavailable(res);
}
const {
page: queryPage = '1',
size: querySize = `${DEFAULT_SCHEDULED_REPORT_LIST_SIZE}`,
} = req.query;
const page = parseInt(queryPage, 10) || 1;
const size = Math.min(
MAX_SCHEDULED_REPORT_LIST_SIZE,
parseInt(querySize, 10) || DEFAULT_SCHEDULED_REPORT_LIST_SIZE
);
const results = await scheduledQuery.list(logger, req, res, user, page, size);
counters.usageCounter();
return res.ok({ body: results, headers: { 'content-type': 'application/json' } });
} catch (err) {
if (err instanceof KibanaResponse) {
return err;
}
throw err;
}
})
);
};
const registerInternalPatchBulkDisable = () => {
// allow scheduled reports to be disabled
const path = SCHEDULED.BULK_DISABLE;
router.patch(
{
path,
security: {
authz: {
enabled: false,
reason: 'This route is opted out from authorization',
},
},
validate: {
body: schema.object({
ids: schema.arrayOf(schema.string(), { minSize: 1, maxSize: 1000 }),
}),
},
options: { access: 'internal' },
},
authorizedUserPreRouting(reporting, async (user, context, req, res) => {
try {
const counters = getCounters(req.route.method, path, reporting.getUsageCounter());
// ensure the async dependencies are loaded
if (!context.reporting) {
return handleUnavailable(res);
}
const { ids } = req.body;
const results = await scheduledQuery.bulkDisable(logger, req, res, ids, user);
counters.usageCounter();
return res.ok({ body: results, headers: { 'content-type': 'application/json' } });
} catch (err) {
if (err instanceof KibanaResponse) {
return err;
}
throw err;
}
})
);
};
registerInternalGetList();
registerInternalPatchBulkDisable();
}

View file

@ -0,0 +1,360 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import rison from '@kbn/rison';
import { BehaviorSubject } from 'rxjs';
import supertest from 'supertest';
import { setupServer } from '@kbn/core-test-helpers-test-utils';
import { coreMock, loggingSystemMock } from '@kbn/core/server/mocks';
import { licensingMock } from '@kbn/licensing-plugin/server/mocks';
import { INTERNAL_ROUTES } from '@kbn/reporting-common';
import { PdfExportType } from '@kbn/reporting-export-types-pdf';
import { createMockConfigSchema } from '@kbn/reporting-mocks-server';
import { ExportTypesRegistry } from '@kbn/reporting-server/export_types_registry';
import { ReportingCore } from '../../../..';
import { reportingMock } from '../../../../mocks';
import {
createMockPluginSetup,
createMockPluginStart,
createMockReportingCore,
} from '../../../../test_helpers';
import { ReportingRequestHandlerContext } from '../../../../types';
import { registerScheduleRoutesInternal } from '../schedule_from_jobparams';
import { FakeRawRequest, KibanaRequest, SavedObjectsClientContract } from '@kbn/core/server';
import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks';
type SetupServerReturn = Awaited<ReturnType<typeof setupServer>>;
const fakeRawRequest: FakeRawRequest = {
headers: {
authorization: `ApiKey skdjtq4u543yt3rhewrh`,
},
path: '/',
};
describe(`POST ${INTERNAL_ROUTES.SCHEDULE_PREFIX}`, () => {
const reportingSymbol = Symbol('reporting');
let server: SetupServerReturn['server'];
let httpSetup: SetupServerReturn['httpSetup'];
let mockExportTypesRegistry: ExportTypesRegistry;
let reportingCore: ReportingCore;
let soClient: SavedObjectsClientContract;
const mockConfigSchema = createMockConfigSchema({
queue: { indexInterval: 'year', timeout: 10000, pollEnabled: true },
});
const mockLogger = loggingSystemMock.createLogger();
const mockCoreSetup = coreMock.createSetup();
const auditLogger = auditLoggerMock.create();
const mockPdfExportType = new PdfExportType(
mockCoreSetup,
mockConfigSchema,
mockLogger,
coreMock.createPluginInitializerContext(mockConfigSchema)
);
beforeEach(async () => {
({ server, httpSetup } = await setupServer(reportingSymbol));
httpSetup.registerRouteHandlerContext<ReportingRequestHandlerContext, 'reporting'>(
reportingSymbol,
'reporting',
() => reportingMock.createStart()
);
const mockSetupDeps = createMockPluginSetup({
security: { license: { isEnabled: () => true, getFeature: () => true } },
router: httpSetup.createRouter(''),
});
const mockStartDeps = await createMockPluginStart(
{
licensing: {
...licensingMock.createStart(),
license$: new BehaviorSubject({
isActive: true,
isAvailable: true,
type: 'gold',
getFeature: () => true,
}),
},
securityService: {
authc: {
apiKeys: { areAPIKeysEnabled: () => true },
getCurrentUser: () => ({ id: '123', roles: ['superuser'], username: 'Tom Riddle' }),
},
audit: {
asScoped: () => auditLogger,
},
},
},
mockConfigSchema
);
reportingCore = await createMockReportingCore(mockConfigSchema, mockSetupDeps, mockStartDeps);
jest.spyOn(reportingCore, 'getHealthInfo').mockResolvedValue({
isSufficientlySecure: true,
hasPermanentEncryptionKey: true,
areNotificationsEnabled: true,
});
mockExportTypesRegistry = new ExportTypesRegistry();
mockExportTypesRegistry.register(mockPdfExportType);
soClient = await reportingCore.getScopedSoClient(fakeRawRequest as unknown as KibanaRequest);
soClient.create = jest.fn().mockImplementation(async (_, opts) => {
return {
id: 'foo',
attributes: opts,
type: 'scheduled-report',
};
});
});
afterEach(async () => {
await server.stop();
});
it('returns 400 if there are no job params', async () => {
registerScheduleRoutesInternal(reportingCore, mockLogger);
await server.start();
await supertest(httpSetup.server.listener)
.post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`)
.expect(400)
.then(({ body }) =>
expect(body.message).toMatchInlineSnapshot(
'"[request body]: expected a plain object value, but found [null] instead."'
)
);
});
it('returns 400 if job params body is invalid', async () => {
registerScheduleRoutesInternal(reportingCore, mockLogger);
await server.start();
await supertest(httpSetup.server.listener)
.post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`)
.send({ jobParams: `foo:`, schedule: { rrule: { freq: 1, interval: 2 } } })
.expect(400)
.then(({ body }) => expect(body.message).toMatchInlineSnapshot('"invalid rison: foo:"'));
});
it('returns 400 export type is invalid', async () => {
registerScheduleRoutesInternal(reportingCore, mockLogger);
await server.start();
await supertest(httpSetup.server.listener)
.post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/TonyHawksProSkater2`)
.send({
schedule: { rrule: { freq: 1, interval: 2 } },
jobParams: rison.encode({ title: `abc` }),
})
.expect(400)
.then(({ body }) =>
expect(body.message).toMatchInlineSnapshot('"Invalid export-type of TonyHawksProSkater2"')
);
});
it('returns 400 on invalid browser timezone', async () => {
registerScheduleRoutesInternal(reportingCore, mockLogger);
await server.start();
await supertest(httpSetup.server.listener)
.post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`)
.send({
jobParams: rison.encode({ browserTimezone: 'America/Amsterdam', title: `abc` }),
schedule: { rrule: { freq: 1, interval: 2 } },
})
.expect(400)
.then(({ body }) =>
expect(body.message).toMatchInlineSnapshot(`"Invalid timezone \\"America/Amsterdam\\"."`)
);
});
it('returns 400 on invalid rrule', async () => {
registerScheduleRoutesInternal(reportingCore, mockLogger);
await server.start();
await supertest(httpSetup.server.listener)
.post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`)
.send({
jobParams: rison.encode({ browserTimezone: 'America/Amsterdam', title: `abc` }),
schedule: { rrule: { freq: 6, interval: 2 } },
})
.expect(400)
.then(({ body }) =>
expect(body.message).toMatchInlineSnapshot(`
"[request body.schedule.rrule]: types that failed validation:
- [request body.schedule.rrule.0.freq]: expected value to equal [1]
- [request body.schedule.rrule.1.freq]: expected value to equal [2]
- [request body.schedule.rrule.2.freq]: expected value to equal [3]"
`)
);
});
it('returns 400 on invalid notification list', async () => {
registerScheduleRoutesInternal(reportingCore, mockLogger);
await server.start();
await supertest(httpSetup.server.listener)
.post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`)
.send({
jobParams: rison.encode({ browserTimezone: 'America/Amsterdam', title: `abc` }),
schedule: { rrule: { freq: 1, interval: 2 } },
notification: {
email: {
to: 'single@email.com',
},
},
})
.expect(400)
.then(({ body }) =>
expect(body.message).toMatchInlineSnapshot(
`"[request body.notification.email.to]: could not parse array value from json input"`
)
);
});
it('returns 400 on empty notification list', async () => {
registerScheduleRoutesInternal(reportingCore, mockLogger);
await server.start();
await supertest(httpSetup.server.listener)
.post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`)
.send({
jobParams: rison.encode({ browserTimezone: 'America/Amsterdam', title: `abc` }),
schedule: { rrule: { freq: 1, interval: 2 } },
notification: {
email: {
to: [],
},
},
})
.expect(400)
.then(({ body }) =>
expect(body.message).toMatchInlineSnapshot(
`"[request body.notification.email]: At least one email address is required"`
)
);
});
it('returns 403 on when no permanent encryption key', async () => {
jest.spyOn(reportingCore, 'getHealthInfo').mockResolvedValueOnce({
isSufficientlySecure: true,
hasPermanentEncryptionKey: false,
areNotificationsEnabled: false,
});
registerScheduleRoutesInternal(reportingCore, mockLogger);
await server.start();
await supertest(httpSetup.server.listener)
.post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`)
.send({
jobParams: rison.encode({ title: `abc` }),
schedule: { rrule: { freq: 1, interval: 2 } },
})
.expect(403)
.then(({ body }) =>
expect(body.message).toMatchInlineSnapshot(
`"Permanent encryption key must be set for scheduled reporting"`
)
);
});
it('returns 403 on when not sufficiently secure', async () => {
jest.spyOn(reportingCore, 'getHealthInfo').mockResolvedValueOnce({
isSufficientlySecure: false,
hasPermanentEncryptionKey: true,
areNotificationsEnabled: false,
});
registerScheduleRoutesInternal(reportingCore, mockLogger);
await server.start();
await supertest(httpSetup.server.listener)
.post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`)
.send({
jobParams: rison.encode({ title: `abc` }),
schedule: { rrule: { freq: 1, interval: 2 } },
})
.expect(403)
.then(({ body }) =>
expect(body.message).toMatchInlineSnapshot(
`"Security and API keys must be enabled for scheduled reporting"`
)
);
});
it('returns 500 if job handler throws an error', async () => {
soClient.create = jest.fn().mockRejectedValue('silly');
registerScheduleRoutesInternal(reportingCore, mockLogger);
await server.start();
await supertest(httpSetup.server.listener)
.post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`)
.send({
jobParams: rison.encode({ title: `abc` }),
schedule: { rrule: { freq: 1, interval: 2 } },
})
.expect(500);
});
it(`returns 200 if job handler doesn't error`, async () => {
registerScheduleRoutesInternal(reportingCore, mockLogger);
await server.start();
await supertest(httpSetup.server.listener)
.post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`)
.send({
jobParams: rison.encode({
title: `abc`,
layout: { id: 'test' },
objectType: 'canvas workpad',
}),
notification: {
email: {
bcc: ['single@email.com'],
},
},
schedule: { rrule: { freq: 1, interval: 2 } },
})
.expect(200)
.then(({ body }) => {
expect(body).toMatchObject({
job: {
created_by: 'Tom Riddle',
id: 'foo',
jobtype: 'printable_pdf_v2',
payload: {
isDeprecated: false,
layout: {
id: 'test',
},
objectType: 'canvas workpad',
title: 'abc',
version: '7.14.0',
},
schedule: { rrule: { freq: 1, interval: 2 } },
},
});
});
});
});

View file

@ -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();
}

View file

@ -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;

View file

@ -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,
});
}

View file

@ -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';

View file

@ -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',
},
},
};

View file

@ -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,
},
},
};

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { rawScheduledReportSchema as rawScheduledReportSchemaV1 } from './v1';

View file

@ -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<typeof rawNotificationSchema>;
export type RawScheduledReport = TypeOf<typeof rawScheduledReportSchema>;

View file

@ -0,0 +1,57 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { schema } from '@kbn/config-schema';
import { scheduleRruleSchema } from '@kbn/task-manager-plugin/server';
const rawLayoutIdSchema = schema.oneOf([
schema.literal('preserve_layout'),
schema.literal('print'),
schema.literal('canvas'),
]);
export const rawNotificationSchema = schema.object({
email: schema.maybe(
schema.object(
{
to: schema.maybe(schema.arrayOf(schema.string())),
bcc: schema.maybe(schema.arrayOf(schema.string())),
cc: schema.maybe(schema.arrayOf(schema.string())),
},
{
validate: (value) => {
const allEmails = new Set([
...(value.to || []),
...(value.bcc || []),
...(value.cc || []),
]);
if (allEmails.size === 0) {
return 'At least one email address is required';
}
},
}
)
),
});
export const rawScheduledReportSchema = schema.object({
createdAt: schema.string(),
createdBy: schema.string(),
enabled: schema.boolean(),
jobType: schema.string(),
meta: schema.object({
isDeprecated: schema.maybe(schema.boolean()),
layout: schema.maybe(rawLayoutIdSchema),
objectType: schema.string(),
}),
migrationVersion: schema.maybe(schema.string()),
notification: schema.maybe(rawNotificationSchema),
payload: schema.string(),
schedule: scheduleRruleSchema,
title: schema.string(),
});

View file

@ -0,0 +1,115 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { elasticsearchServiceMock } from '@kbn/core/server/mocks';
import { notificationsMock } from '@kbn/notifications-plugin/server/mocks';
import { createMockConfigSchema } from '@kbn/reporting-mocks-server';
import { set } from '@kbn/safer-lodash-set';
import { ReportingCore } from '../..';
import { createMockReportingCore } from '../../test_helpers';
import { EmailNotificationService } from './email_notification_service';
describe('EmailNotificationService', () => {
const notifications = notificationsMock.createStart();
let mockEsClient: ReturnType<typeof elasticsearchServiceMock.createElasticsearchClient>;
let emailNotificationService: EmailNotificationService;
let mockCore: ReportingCore;
beforeEach(async () => {
jest.clearAllMocks();
notifications.isEmailServiceAvailable.mockReturnValue(true);
emailNotificationService = new EmailNotificationService({
notifications,
});
const reportingConfig = {
index: '.reporting-test',
queue: { indexInterval: 'week' },
statefulSettings: { enabled: true },
};
mockCore = await createMockReportingCore(createMockConfigSchema(reportingConfig));
mockEsClient = (await mockCore.getEsClient()).asInternalUser as typeof mockEsClient;
});
it('notify()', async () => {
const base64Content = Buffer.from('test-output').toString('base64');
mockEsClient.search.mockResponse(
set<any>({}, 'hits.hits.0._source', {
jobtype: 'pdf',
output: {
content: base64Content,
size: 12,
},
})
);
await emailNotificationService.notify({
reporting: mockCore,
index: '.reporting-test-1234',
id: '1234',
filename: 'test-report.pdf',
contentType: 'test-content-type',
relatedObject: {
id: 'report-so-id',
type: 'scheduled-report',
namespace: 'space1',
},
emailParams: {
to: ['test@test.com'],
subject: 'Scheduled report for 04/18/2025',
spaceId: 'space1',
},
});
expect(notifications.getEmailService().sendAttachmentEmail).toHaveBeenCalledWith({
attachments: [
{
content: 'dGVzdC1vdXRwdXR0ZXN0LW91dHB1dA==',
contentType: 'test-content-type',
encoding: 'base64',
filename: 'test-report.pdf',
},
],
context: {
relatedObjects: [
{
id: 'report-so-id',
namespace: 'space1',
type: 'scheduled-report',
},
],
},
message: 'Your scheduled report is attached for you to download or share.',
spaceId: 'space1',
subject: 'Scheduled report for 04/18/2025',
to: ['test@test.com'],
});
});
it('throws an error when the email service is not available', async () => {
notifications.isEmailServiceAvailable.mockReturnValue(false);
await expect(
emailNotificationService.notify({
reporting: mockCore,
index: '.reporting-test-1234',
id: '1234',
filename: 'test-report.pdf',
contentType: 'test-content-type',
relatedObject: {
id: 'report-so-id',
type: 'scheduled-report',
namespace: 'space1',
},
emailParams: {
to: ['test@test.com'],
subject: 'Scheduled report for 04/18/2025',
spaceId: 'space1',
},
})
).rejects.toThrowErrorMatchingInlineSnapshot(`"Email notification service is not available"`);
expect(notifications.getEmailService().sendAttachmentEmail).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,81 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { NotificationsPluginStart } from '@kbn/notifications-plugin/server';
import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common';
import type { NotificationService, NotifyArgs } from './types';
import { ReportingCore } from '../..';
import { getContentStream } from '../../lib';
export interface Attachment {
content: string;
contentType?: string;
encoding?: string;
filename: string;
}
export class EmailNotificationService implements NotificationService {
private readonly notifications: NotificationsPluginStart;
constructor({ notifications }: { notifications: NotificationsPluginStart }) {
this.notifications = notifications;
}
private async getAttachments(
reporting: ReportingCore,
index: string,
id: string,
filename: string,
contentType?: string | null
): Promise<Attachment[]> {
const stream = await getContentStream(reporting, { id, index });
const buffers: Buffer[] = [];
for await (const chunk of stream) {
buffers.push(chunk);
}
const content = Buffer.concat(buffers);
return [
{
content: content.toString('base64'),
...(contentType ? { contentType } : {}),
filename,
encoding: 'base64',
},
];
}
public async notify({
reporting,
index,
id,
contentType,
filename,
relatedObject,
emailParams,
}: NotifyArgs) {
if (!this.notifications.isEmailServiceAvailable()) {
throw new Error('Email notification service is not available');
}
const attachments = await this.getAttachments(reporting, index, id, filename, contentType);
const { to, bcc, cc, subject, spaceId } = emailParams;
const message = 'Your scheduled report is attached for you to download or share.';
await this.notifications.getEmailService().sendAttachmentEmail({
to,
bcc,
cc,
subject,
message,
attachments,
spaceId: spaceId ?? DEFAULT_SPACE_ID,
context: {
relatedObjects: [relatedObject],
},
});
}
}

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { RelatedSavedObject } from '@kbn/notifications-plugin/server/services/types';
import { ReportingCore } from '../..';
export interface NotifyArgs {
reporting: ReportingCore;
index: string;
id: string;
contentType?: string | null;
filename: string;
relatedObject: RelatedSavedObject;
emailParams: {
to?: string[];
bcc?: string[];
cc?: string[];
subject: string;
spaceId?: string;
};
}
export interface NotificationService {
notify: (args: NotifyArgs) => Promise<void>;
}

View file

@ -15,8 +15,10 @@ import {
docLinksServiceMock,
elasticsearchServiceMock,
loggingSystemMock,
savedObjectsClientMock,
statusServiceMock,
} from '@kbn/core/server/mocks';
import { actionsMock } from '@kbn/actions-plugin/server/mocks';
import { dataPluginMock } from '@kbn/data-plugin/server/mocks';
import { discoverPluginMock } from '@kbn/discover-plugin/server/mocks';
import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks';
@ -40,10 +42,22 @@ export const createMockPluginSetup = (
setupMock: Partial<Record<keyof ReportingInternalSetup, any>>
): ReportingInternalSetup => {
return {
encryptedSavedObjects: encryptedSavedObjectsMock.createSetup(),
actions: {
...actionsMock.createSetup(),
getActionsConfigurationUtilities: jest.fn().mockReturnValue({
validateEmailAddresses: jest.fn(),
}),
},
encryptedSavedObjects: encryptedSavedObjectsMock.createSetup({ canEncrypt: true }),
features: featuresPluginMock.createSetup(),
basePath: { set: jest.fn() },
router: { get: jest.fn(), post: jest.fn(), put: jest.fn(), delete: jest.fn() },
router: {
get: jest.fn(),
patch: jest.fn(),
post: jest.fn(),
put: jest.fn(),
delete: jest.fn(),
},
security: securityMock.createSetup(),
taskManager: taskManagerMock.createSetup(),
logger: loggingSystemMock.createLogger(),
@ -56,6 +70,7 @@ export const createMockPluginSetup = (
const coreSetupMock = coreMock.createSetup();
const coreStartMock = coreMock.createStart();
const logger = loggingSystemMock.createLogger();
const savedObjectsClient = savedObjectsClientMock.create();
const createMockReportingStore = async (config: ReportingConfigType) => {
const mockConfigSchema = createMockConfigSchema(config);
@ -71,7 +86,10 @@ export const createMockPluginStart = async (
return {
analytics: coreSetupMock.analytics,
esClient: elasticsearchServiceMock.createClusterClient(),
savedObjects: { getScopedClient: jest.fn() },
savedObjects: {
getScopedClient: jest.fn().mockReturnValue(savedObjectsClient),
createInternalRepository: jest.fn().mockReturnValue(savedObjectsClient),
},
uiSettings: { asScopedToClient: () => ({ get: jest.fn() }) },
discover: discoverPluginMock.createStartContract(),
data: dataPluginMock.createStartContract(),

View file

@ -12,7 +12,7 @@ import type { DiscoverServerPluginStart } from '@kbn/discover-plugin/server';
import type { FeaturesPluginSetup } from '@kbn/features-plugin/server';
import type { FieldFormatsStart } from '@kbn/field-formats-plugin/server';
import type { LicensingPluginStart } from '@kbn/licensing-plugin/server';
import type { UrlOrUrlLocatorTuple } from '@kbn/reporting-common/types';
import type { ReportSource, UrlOrUrlLocatorTuple } from '@kbn/reporting-common/types';
import type { ReportApiJSON } from '@kbn/reporting-common/types';
import type { ReportingConfigType } from '@kbn/reporting-server';
import type { ScreenshotModePluginSetup } from '@kbn/screenshot-mode-plugin/server';
@ -21,18 +21,24 @@ import type {
PngScreenshotOptions as BasePngScreenshotOptions,
ScreenshottingStart,
} from '@kbn/screenshotting-plugin/server';
import type { SecurityPluginSetup } from '@kbn/security-plugin/server';
import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/server';
import type { SpacesPluginSetup } from '@kbn/spaces-plugin/server';
import type {
RruleSchedule,
TaskManagerSetupContract,
TaskManagerStartContract,
} from '@kbn/task-manager-plugin/server';
import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server';
import type { NotificationsPluginStart } from '@kbn/notifications-plugin/server';
import type { PluginSetupContract as ActionsPluginSetupContract } from '@kbn/actions-plugin/server';
import { ExportTypesRegistry } from '@kbn/reporting-server/export_types_registry';
import type { AuthenticatedUser } from '@kbn/core-security-common';
import { EncryptedSavedObjectsPluginSetup } from '@kbn/encrypted-saved-objects-plugin/server';
import type { NotificationsPluginStart } from '@kbn/notifications-plugin/server';
import {
RawNotification,
RawScheduledReport,
} from './saved_objects/scheduled_report/schemas/latest';
/**
* Plugin Setup Contract
@ -50,6 +56,7 @@ export type ReportingUser = { username: AuthenticatedUser['username'] } | false;
export type ScrollConfig = ReportingConfigType['csv']['scroll'];
export interface ReportingSetupDeps {
actions: ActionsPluginSetupContract;
encryptedSavedObjects: EncryptedSavedObjectsPluginSetup;
features: FeaturesPluginSetup;
screenshotMode: ScreenshotModePluginSetup;
@ -66,6 +73,7 @@ export interface ReportingStartDeps {
licensing: LicensingPluginStart;
notifications: NotificationsPluginStart;
taskManager: TaskManagerStartContract;
security?: SecurityPluginStart;
screenshotting?: ScreenshottingStart;
}
@ -92,6 +100,44 @@ export interface ReportingJobResponse {
job: ReportApiJSON;
}
export type ScheduledReportApiJSON = Omit<
ReportSource,
'attempts' | 'migration_version' | 'output' | 'payload' | 'status'
> & {
id: string;
migration_version?: string;
notification?: RawNotification;
payload: Omit<ReportSource['payload'], 'headers'>;
schedule: RruleSchedule;
};
export interface ScheduledReportingJobResponse {
/**
* Details of a new report job that was requested
* @public
*/
job: ScheduledReportApiJSON;
}
export type ScheduledReportType = Omit<RawScheduledReport, 'schedule'> & {
schedule: RruleSchedule;
};
export interface ListScheduledReportApiJSON {
id: string;
created_at: RawScheduledReport['createdAt'];
created_by: RawScheduledReport['createdBy'];
enabled: RawScheduledReport['enabled'];
jobtype: RawScheduledReport['jobType'];
last_run: string | undefined;
next_run: string | undefined;
notification: RawScheduledReport['notification'];
payload?: ReportApiJSON['payload'];
schedule: RruleSchedule;
space_id: string;
title: RawScheduledReport['title'];
}
export interface PdfScreenshotOptions extends Omit<BasePdfScreenshotOptions, 'timeouts' | 'urls'> {
urls: UrlOrUrlLocatorTuple[];
}

View file

@ -5,6 +5,7 @@
},
"include": ["common/**/*", "public/**/*", "server/**/*", "../../../../../typings/**/*"],
"kbn_references": [
"@kbn/actions-plugin",
"@kbn/core",
"@kbn/data-plugin",
"@kbn/discover-plugin",
@ -52,7 +53,11 @@
"@kbn/react-kibana-mount",
"@kbn/core-security-common",
"@kbn/core-http-server-utils",
"@kbn/core-saved-objects-server",
"@kbn/rrule",
"@kbn/notifications-plugin",
"@kbn/spaces-utils",
"@kbn/logging-mocks",
],
"exclude": [
"target/**/*",

View file

@ -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 [

View file

@ -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'],

View file

@ -43,8 +43,9 @@ export interface PlainTextEmail {
};
}
export interface AttachmentEmail extends PlainTextEmail {
export interface AttachmentEmail extends Omit<PlainTextEmail, 'to'> {
attachments: Attachment[];
to?: string[];
bcc?: string[];
cc?: string[];
spaceId: string;

View file

@ -156,6 +156,70 @@ describe('send_email module', () => {
`);
});
test('handles email with attachments', async () => {
const sendEmailOptions = getSendEmailOptions({ transport: { service: 'other' } });
const result = await sendEmail(
mockLogger,
{
...sendEmailOptions,
attachments: [
{
content: 'dGVzdC1vdXRwdXR0ZXN0LW91dHB1dA==',
contentType: 'test-content-type',
encoding: 'base64',
filename: 'report.pdf',
},
],
},
connectorTokenClient,
connectorUsageCollector
);
expect(result).toBe(sendMailMockResult);
expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"auth": Object {
"pass": "changeme",
"user": "elastic",
},
"host": undefined,
"port": undefined,
"secure": false,
"tls": Object {
"rejectUnauthorized": true,
},
},
]
`);
expect(sendMailMock.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"attachments": Array [
Object {
"content": "dGVzdC1vdXRwdXR0ZXN0LW91dHB1dA==",
"contentType": "test-content-type",
"encoding": "base64",
"filename": "report.pdf",
},
],
"bcc": Array [],
"cc": Array [
"bob@example.com",
"robert@example.com",
],
"from": "fred@example.com",
"html": "<p>a message</p>
",
"subject": "a subject",
"text": "a message",
"to": Array [
"jim@example.com",
],
},
]
`);
});
test('uses OAuth 2.0 Client Credentials authentication for email using "exchange_server" service', async () => {
const sendEmailGraphApiMock = sendEmailGraphApi as jest.Mock;
const getOAuthClientCredentialsAccessTokenMock =
@ -197,6 +261,7 @@ describe('send_email module', () => {
expect(sendEmailGraphApiMock.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"attachments": Array [],
"headers": Object {
"Authorization": "Bearer dfjsdfgdjhfgsjdf",
"Content-Type": "application/json",

View file

@ -78,6 +78,7 @@ export async function sendEmail(
): Promise<unknown> {
const { transport, content } = options;
const { message, messageHTML } = content;
const attachments = options.attachments ?? [];
const renderedMessage = messageHTML ?? htmlFromMarkdown(logger, message);
@ -87,10 +88,17 @@ export async function sendEmail(
options,
renderedMessage,
connectorTokenClient,
connectorUsageCollector
connectorUsageCollector,
attachments
);
} else {
return await sendEmailWithNodemailer(logger, options, renderedMessage, connectorUsageCollector);
return await sendEmailWithNodemailer(
logger,
options,
renderedMessage,
connectorUsageCollector,
attachments
);
}
}
@ -100,7 +108,8 @@ export async function sendEmailWithExchange(
options: SendEmailOptions,
messageHTML: string,
connectorTokenClient: ConnectorTokenClientContract,
connectorUsageCollector: ConnectorUsageCollector
connectorUsageCollector: ConnectorUsageCollector,
attachments: Attachment[]
): Promise<unknown> {
const { transport, configurationUtilities, connectorId } = options;
const { clientId, clientSecret, tenantId, oauthTokenUrl } = transport;
@ -167,6 +176,7 @@ export async function sendEmailWithExchange(
options,
headers,
messageHTML,
attachments,
},
logger,
configurationUtilities,
@ -180,7 +190,8 @@ async function sendEmailWithNodemailer(
logger: Logger,
options: SendEmailOptions,
messageHTML: string,
connectorUsageCollector: ConnectorUsageCollector
connectorUsageCollector: ConnectorUsageCollector,
attachments: Attachment[]
): Promise<unknown> {
const { transport, routing, content, configurationUtilities, hasAuth } = options;
const { service } = transport;
@ -197,6 +208,7 @@ async function sendEmailWithNodemailer(
subject,
html: messageHTML,
text: message,
...(attachments.length > 0 && { attachments }),
};
// The transport options do not seem to be exposed as a type, and we reference

View file

@ -16,7 +16,7 @@ import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.moc
import type { CustomHostSettings } from '@kbn/actions-plugin/server/config';
import type { ProxySettings } from '@kbn/actions-plugin/server/types';
import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types';
import { sendEmailGraphApi } from './send_email_graph_api';
import { sendEmailGraphApi, sendEmailWithAttachments } from './send_email_graph_api';
const createAxiosInstanceMock = axios.create as jest.Mock;
const axiosInstanceMock = jest.fn();
@ -24,6 +24,7 @@ const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
describe('sendEmailGraphApi', () => {
beforeEach(() => {
jest.clearAllMocks();
createAxiosInstanceMock.mockReturnValue(axiosInstanceMock);
});
const configurationUtilities = actionsConfigMock.create();
@ -42,6 +43,7 @@ describe('sendEmailGraphApi', () => {
options: getSendEmailOptions(),
messageHTML: 'test1',
headers: {},
attachments: [],
},
logger,
configurationUtilities,
@ -137,12 +139,13 @@ describe('sendEmailGraphApi', () => {
options: getSendEmailOptions(),
messageHTML: 'test2',
headers: { Authorization: 'Bearer 1234567' },
attachments: [],
},
logger,
configurationUtilities,
connectorUsageCollector
);
expect(axiosInstanceMock.mock.calls[1]).toMatchInlineSnapshot(`
expect(axiosInstanceMock.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"https://graph.microsoft.com/v1.0/users/fred@example.com/sendMail",
Object {
@ -235,12 +238,13 @@ describe('sendEmailGraphApi', () => {
options: getSendEmailOptions(),
messageHTML: 'test3',
headers: {},
attachments: [],
},
logger,
configurationUtilities,
connectorUsageCollector
);
expect(axiosInstanceMock.mock.calls[2]).toMatchInlineSnapshot(`
expect(axiosInstanceMock.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"https://test/users/fred@example.com/sendMail",
Object {
@ -334,7 +338,7 @@ describe('sendEmailGraphApi', () => {
await expect(
sendEmailGraphApi(
{ options: getSendEmailOptions(), messageHTML: 'test1', headers: {} },
{ options: getSendEmailOptions(), messageHTML: 'test1', headers: {}, attachments: [] },
logger,
configurationUtilities,
connectorUsageCollector
@ -349,6 +353,243 @@ describe('sendEmailGraphApi', () => {
]
`);
});
describe('sendEmailWithAttachments', () => {
test('email adds a small attachment', async () => {
const connectorUsageCollector = new ConnectorUsageCollector({
logger,
connectorId: 'test-connector-id',
});
// create draft
axiosInstanceMock.mockReturnValueOnce({
status: 201,
data: { id: 'draftId' },
});
// add small attachment
axiosInstanceMock.mockReturnValueOnce({
status: 201,
});
// send draft
axiosInstanceMock.mockReturnValueOnce({
status: 202,
});
const axiosInstance = axios.create();
await sendEmailWithAttachments({
sendEmailOptions: {
options: getSendEmailOptions(),
messageHTML: 'test1',
attachments: [
{
content: 'dGVzdC1vdXRwdXR0ZXN0LW91dHB1dA==',
contentType: 'test-content-type',
encoding: 'base64',
filename: 'report.pdf',
},
],
headers: {},
},
logger,
configurationUtilities,
connectorUsageCollector,
axiosInstance,
});
expect(axiosInstanceMock).toHaveBeenCalledTimes(3);
const [createDraftUrl, createDraft] = axiosInstanceMock.mock.calls[0];
expect(createDraftUrl).toEqual(
'https://graph.microsoft.com/v1.0/users/fred@example.com/messages'
);
expect(createDraft.data).toEqual({
bccRecipients: [],
body: {
content: 'test1',
contentType: 'HTML',
},
ccRecipients: [
{
emailAddress: {
address: 'bob@example.com',
},
},
{
emailAddress: {
address: 'robert@example.com',
},
},
],
subject: 'a subject',
toRecipients: [
{
emailAddress: {
address: 'jim@example.com',
},
},
],
});
const [addAttachmentUrl, addAttachment] = axiosInstanceMock.mock.calls[1];
expect(addAttachmentUrl).toEqual(
'https://graph.microsoft.com/v1.0/users/fred@example.com/messages/draftId/attachments'
);
expect(addAttachment.data).toEqual({
'@odata.type': '#microsoft.graph.fileAttachment',
contentBytes: 'dGVzdC1vdXRwdXR0ZXN0LW91dHB1dA==',
contentType: 'test-content-type',
name: 'report.pdf',
});
const [sendDraftUrl] = axiosInstanceMock.mock.calls[2];
expect(sendDraftUrl).toEqual(
'https://graph.microsoft.com/v1.0/users/fred@example.com/messages/draftId/send'
);
});
test('email uploads a large attachment', async () => {
const connectorUsageCollector = new ConnectorUsageCollector({
logger,
connectorId: 'test-connector-id',
});
// create draft
axiosInstanceMock.mockReturnValueOnce({
status: 201,
data: { id: 'draftId' },
});
// create upload session
axiosInstanceMock.mockReturnValueOnce({
status: 201,
data: { uploadUrl: 'http://test-upload-session.com' },
});
// upload attachment 1/3
axiosInstanceMock.mockReturnValueOnce({
status: 200,
});
// upload attachment 2/3
axiosInstanceMock.mockReturnValueOnce({
status: 200,
});
// upload attachment 3/3
axiosInstanceMock.mockReturnValueOnce({
status: 200,
});
// close upload session
axiosInstanceMock.mockReturnValueOnce({
status: 204,
});
// send draft
axiosInstanceMock.mockReturnValueOnce({
status: 202,
});
const axiosInstance = axios.create();
await sendEmailWithAttachments(
{
sendEmailOptions: {
options: getSendEmailOptions(),
messageHTML: 'test1',
attachments: [
{
content: 'dGVzdC1vdXRwdXR0ZXN0LW91dHB1dA==',
contentType: 'test-content-type',
encoding: 'base64',
filename: 'report.pdf',
},
],
headers: {},
},
logger,
configurationUtilities,
connectorUsageCollector,
axiosInstance,
},
30,
10
);
expect(axiosInstanceMock).toHaveBeenCalledTimes(7);
const [createDraftUrl, createDraft] = axiosInstanceMock.mock.calls[0];
expect(createDraftUrl).toEqual(
'https://graph.microsoft.com/v1.0/users/fred@example.com/messages'
);
expect(createDraft.data).toEqual({
bccRecipients: [],
body: {
content: 'test1',
contentType: 'HTML',
},
ccRecipients: [
{
emailAddress: {
address: 'bob@example.com',
},
},
{
emailAddress: {
address: 'robert@example.com',
},
},
],
subject: 'a subject',
toRecipients: [
{
emailAddress: {
address: 'jim@example.com',
},
},
],
});
const [createUploadSessionUrl, createUploadSession] = axiosInstanceMock.mock.calls[1];
expect(createUploadSessionUrl).toEqual(
'https://graph.microsoft.com/v1.0/users/fred@example.com/messages/draftId/attachments/createUploadSession'
);
expect(createUploadSession.data).toEqual({
AttachmentItem: {
attachmentType: 'file',
name: 'report.pdf',
size: 22,
},
});
const [uploadAttachmentUrl1, uploadAttachment1] = axiosInstanceMock.mock.calls[2];
expect(uploadAttachmentUrl1).toEqual('http://test-upload-session.com');
expect(uploadAttachment1.data).toBeTruthy();
expect(uploadAttachment1.headers).toEqual({
'Content-Length': '10',
'Content-Range': 'bytes 0-9/22',
'Content-Type': 'application/octet-stream',
});
const [uploadAttachmentUrl2, uploadAttachment2] = axiosInstanceMock.mock.calls[3];
expect(uploadAttachmentUrl2).toEqual('http://test-upload-session.com');
expect(uploadAttachment2.data).toBeTruthy();
expect(uploadAttachment2.headers).toEqual({
'Content-Length': '10',
'Content-Range': 'bytes 10-19/22',
'Content-Type': 'application/octet-stream',
});
const [uploadAttachmentUrl3, uploadAttachment3] = axiosInstanceMock.mock.calls[4];
expect(uploadAttachmentUrl3).toEqual('http://test-upload-session.com');
expect(uploadAttachment3.data).toBeTruthy();
expect(uploadAttachment3.headers).toEqual({
'Content-Length': '2',
'Content-Range': 'bytes 20-21/22',
'Content-Type': 'application/octet-stream',
});
const [closeUploadSessionUrl] = axiosInstanceMock.mock.calls[5];
expect(closeUploadSessionUrl).toEqual('http://test-upload-session.com');
const [sendDraftUrl] = axiosInstanceMock.mock.calls[6];
expect(sendDraftUrl).toEqual(
'https://graph.microsoft.com/v1.0/users/fred@example.com/messages/draftId/send'
);
});
});
});
function getSendEmailOptions(

View file

@ -14,6 +14,10 @@ import { request } from '@kbn/actions-plugin/server/lib/axios_utils';
import type { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config';
import type { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types';
import type { SendEmailOptions } from './send_email';
import type { Attachment } from '.';
const SMALL_ATTACHMENT_LIMIT = 3 * 1024 * 1024; // 3mb
const ATTACHMENT_CHUNK_SIZE = 2 * 1024 * 1024; // 2mb
export async function sendEmailGraphApi(
sendEmailOptions: SendEmailGraphApiOptions,
@ -22,11 +26,54 @@ export async function sendEmailGraphApi(
connectorUsageCollector: ConnectorUsageCollector,
axiosInstance?: AxiosInstance
): Promise<AxiosResponse> {
const { options, headers, messageHTML } = sendEmailOptions;
// Create a new axios instance if one is not provided
axiosInstance = axiosInstance ?? axios.create();
const { attachments } = sendEmailOptions;
if (attachments.length > 0) {
logger.debug('[MS Exchange] sending email with attachments');
return sendEmailWithAttachments({
sendEmailOptions,
logger,
configurationUtilities,
connectorUsageCollector,
axiosInstance,
});
}
return sendEmail({
sendEmailOptions,
logger,
configurationUtilities,
connectorUsageCollector,
axiosInstance,
});
}
interface SendEmailGraphApiOptions {
options: SendEmailOptions;
headers: Record<string, string>;
messageHTML: string;
attachments: Attachment[];
}
interface SendEmailParams {
sendEmailOptions: SendEmailGraphApiOptions;
logger: Logger;
configurationUtilities: ActionsConfigurationUtilities;
connectorUsageCollector: ConnectorUsageCollector;
axiosInstance: AxiosInstance;
}
async function sendEmail({
sendEmailOptions,
logger,
configurationUtilities,
connectorUsageCollector,
axiosInstance,
}: SendEmailParams): Promise<AxiosResponse> {
const { options, headers, messageHTML } = sendEmailOptions;
// POST /users/{id | userPrincipalName}/sendMail
const res = await request({
axios: axiosInstance,
@ -46,15 +93,58 @@ export async function sendEmailGraphApi(
}
const errString = stringify(res.data);
logger.warn(
`error thrown sending Microsoft Exchange email for clientID: ${sendEmailOptions.options.transport.clientId}: ${errString}`
`error thrown sending Microsoft Exchange email for clientID: ${options.transport.clientId}: ${errString}`
);
throw new Error(errString);
}
interface SendEmailGraphApiOptions {
options: SendEmailOptions;
headers: Record<string, string>;
messageHTML: string;
export async function sendEmailWithAttachments(
params: SendEmailParams,
smallAttachmentLimit: number = SMALL_ATTACHMENT_LIMIT,
attachmentChunkSize: number = ATTACHMENT_CHUNK_SIZE
): Promise<AxiosResponse> {
const logger = params.logger.get('ms-exchange');
logger.debug('Creating draft email');
const emailId = await createDraft(params);
const attachments = params.sendEmailOptions.attachments;
for (const attachment of attachments) {
const size = Buffer.byteLength(attachment.content);
if (size < smallAttachmentLimit) {
// If attachment is smaller than the limit, add the attachment to the draft email
logger.debug('Attachment is smaller than 2Mb, attaching to draft');
await addAttachment(emailId, attachment, params);
} else {
// If attachment is larger than the limit,
// create an upload session and upload attachment in chunks to the draft email
const buffer = Buffer.from(attachment.content, attachment.encoding as BufferEncoding);
const bufferSize = buffer.length;
logger.debug('Attachment is larger than 2Mb, creating upload session');
const uploadUrl = await createUploadSession(emailId, attachment.filename, bufferSize, params);
logger.debug(`UploadUrl: ${uploadUrl}`);
const chunks = getAttachmentChunks(buffer, bufferSize, attachmentChunkSize);
let start = 0;
let count = 1;
for (const chunk of chunks) {
const end = start + chunk.length - 1;
const headers = {
'Content-Type': 'application/octet-stream',
'Content-Length': `${chunk.length}`,
'Content-Range': `bytes ${start}-${end}/${bufferSize}`,
};
logger.debug(`Uploading chunk ${count} of ${chunks.length}`);
await uploadAttachmentChunk(uploadUrl, chunk, headers, params);
start = start + chunk.length;
count++;
}
logger.debug('Closing upload session');
await closeUploadSession(uploadUrl, params);
}
}
logger.debug('Sending draft email');
return sendDraft(emailId, params);
}
function getMessage(emailOptions: SendEmailOptions, messageHTML: string) {
@ -86,3 +176,254 @@ function getMessage(emailOptions: SendEmailOptions, messageHTML: string) {
},
};
}
function getAttachmentChunks(buffer: Buffer, size: number, attachmentChunkSize: number): Buffer[] {
const chunks: Buffer[] = [];
let start = 0;
while (start < size) {
const end = Math.min(start + attachmentChunkSize, size);
const chunk = buffer.subarray(start, end);
chunks.push(chunk);
start = end;
}
return chunks;
}
async function createDraft({
sendEmailOptions,
logger,
configurationUtilities,
connectorUsageCollector,
axiosInstance,
}: SendEmailParams): Promise<string> {
const { options, headers, messageHTML } = sendEmailOptions;
// POST /users/{id | userPrincipalName}/messages
const { message } = getMessage(options, messageHTML);
const res = await request({
axios: axiosInstance,
url: `${configurationUtilities.getMicrosoftGraphApiUrl()}/users/${
options.routing.from
}/messages`,
method: 'post',
logger,
data: message,
headers,
configurationUtilities,
validateStatus: () => true,
connectorUsageCollector,
});
if (res.status !== 201) {
const errString = stringify(res.data);
logger.warn(
`error thrown creating Microsoft Exchange email with attachments for clientID: ${options.transport.clientId}: ${errString}`
);
throw new Error(errString);
}
return res.data.id;
}
async function sendDraft(
emailId: string,
{
sendEmailOptions,
logger,
configurationUtilities,
connectorUsageCollector,
axiosInstance,
}: SendEmailParams
): Promise<AxiosResponse> {
const { options, headers } = sendEmailOptions;
// POST /users/{id | userPrincipalName}/messages/{emailId}/send
const res = await request({
axios: axiosInstance,
url: `${configurationUtilities.getMicrosoftGraphApiUrl()}/users/${
options.routing.from
}/messages/${emailId}/send`,
method: 'post',
logger,
data: {},
headers,
configurationUtilities,
validateStatus: () => true,
connectorUsageCollector,
});
if (res.status === 202) {
return res.data;
}
const errString = stringify(res.data);
logger.warn(
`error thrown sending Microsoft Exchange email with attachments for clientID: ${options.transport.clientId}: ${errString}`
);
throw new Error(errString);
}
async function createUploadSession(
emailId: string,
name: string,
size: number,
{
sendEmailOptions,
logger,
configurationUtilities,
connectorUsageCollector,
axiosInstance,
}: SendEmailParams
): Promise<string> {
const { options, headers } = sendEmailOptions;
// POST /users/{id | userPrincipalName}/messages/{emailId}/attachments/createUploadSession
const res = await request({
axios: axiosInstance,
url: `${configurationUtilities.getMicrosoftGraphApiUrl()}/users/${
options.routing.from
}/messages/${emailId}/attachments/createUploadSession`,
method: 'post',
logger,
data: {
AttachmentItem: {
attachmentType: 'file',
name,
size,
},
},
headers,
configurationUtilities,
validateStatus: () => true,
connectorUsageCollector,
});
if (res.status !== 201) {
const errString = stringify(res.data);
logger.warn(
`error thrown creating Microsoft Exchange attachment upload session for clientID: ${options.transport.clientId}: ${errString}`
);
throw new Error(errString);
}
return res.data.uploadUrl;
}
async function closeUploadSession(
uploadUrl: string,
{
sendEmailOptions,
logger,
configurationUtilities,
connectorUsageCollector,
axiosInstance,
}: SendEmailParams
): Promise<AxiosResponse> {
const { options } = sendEmailOptions;
const res = await request({
axios: axiosInstance,
url: uploadUrl,
method: 'delete',
logger,
configurationUtilities,
validateStatus: () => true,
connectorUsageCollector,
});
if (res.status === 204) {
return res.data;
}
const errString = stringify(`${res.status} ${res.statusText}`);
logger.warn(
`error thrown closing Microsoft Exchange attachment upload session for clientID: ${options.transport.clientId}: ${errString}`
);
throw new Error(errString);
}
async function addAttachment(
emailId: string,
attachment: Attachment,
{
sendEmailOptions,
logger,
configurationUtilities,
connectorUsageCollector,
axiosInstance,
}: SendEmailParams
): Promise<AxiosResponse> {
const { options, headers } = sendEmailOptions;
const responseSettings = configurationUtilities.getResponseSettings();
// POST /users/{id | userPrincipalName}/messages/{emailId}/attachments
const res = await request({
axios: axiosInstance,
url: `${configurationUtilities.getMicrosoftGraphApiUrl()}/users/${
options.routing.from
}/messages/${emailId}/attachments`,
method: 'post',
logger,
data: {
'@odata.type': '#microsoft.graph.fileAttachment',
name: attachment.filename,
contentType: attachment.contentType,
contentBytes: attachment.content,
},
headers,
configurationUtilities: {
...configurationUtilities,
// override maxContentLength config for requests with attachments
getResponseSettings: () => ({
...responseSettings,
maxContentLength: SMALL_ATTACHMENT_LIMIT,
}),
},
validateStatus: () => true,
connectorUsageCollector,
});
if (res.status === 201) {
return res.data;
}
const errString = stringify(res.data);
logger.warn(
`error thrown adding attachment to Microsoft Exchange email for clientID: ${options.transport.clientId}: ${errString}`
);
throw new Error(errString);
}
async function uploadAttachmentChunk(
uploadUrl: string,
chunk: Buffer,
headers: Record<string, string>,
{
sendEmailOptions,
logger,
configurationUtilities,
connectorUsageCollector,
axiosInstance,
}: SendEmailParams
): Promise<void> {
const { options } = sendEmailOptions;
const responseSettings = configurationUtilities.getResponseSettings();
const res = await request({
axios: axiosInstance,
url: uploadUrl,
method: 'put',
logger,
data: chunk,
headers,
configurationUtilities: {
...configurationUtilities,
// Override maxContentLength config for requests with attachments
getResponseSettings: () => ({
...responseSettings,
maxContentLength: SMALL_ATTACHMENT_LIMIT,
}),
},
validateStatus: () => true,
connectorUsageCollector,
});
if (res.status !== 200 && res.status !== 201) {
const errString = stringify(res.data);
logger.warn(
`error thrown uploading attachment to Microsoft Exchange email for clientID: ${options.transport.clientId}: ${errString}`
);
throw new Error(errString);
}
}

View file

@ -16,4 +16,5 @@ export const CONCURRENCY_ALLOW_LIST_BY_TASK_TYPE: string[] = [
// task types requiring a concurrency
'report:execute',
'report:execute-scheduled',
];

View file

@ -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';

View file

@ -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],
},

View file

@ -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';

View file

@ -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])),
});

View file

@ -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;
}

View file

@ -35,6 +35,9 @@ export const REMOVED_TYPES: string[] = [
export const SHARED_CONCURRENCY_TASKS: string[][] = [
// for testing
['sampleTaskSharedConcurrencyType1', 'sampleTaskSharedConcurrencyType2'],
// reporting
['report:execute', 'report:execute-scheduled'],
];
/**

View file

@ -127,6 +127,7 @@ export default function ({ getService }: FtrProviderContext) {
'inventory',
'logs',
'maintenanceWindow',
'manageReporting',
'maps_v2',
'osquery',
'rulesSettings',
@ -210,6 +211,7 @@ export default function ({ getService }: FtrProviderContext) {
'fleet',
'fleetv2',
'entityManager',
'manageReporting',
];
const features = body.filter(

View file

@ -208,6 +208,7 @@ export default function ({ getService }: FtrProviderContext) {
infrastructure: ['all', 'read', 'minimal_all', 'minimal_read'],
logs: ['all', 'read', 'minimal_all', 'minimal_read'],
dataQuality: ['all', 'read', 'minimal_all', 'minimal_read'],
manageReporting: ['all', 'read', 'minimal_all', 'minimal_read'],
apm: ['all', 'read', 'minimal_all', 'minimal_read', 'settings_save'],
discover: [
'all',

View file

@ -81,6 +81,7 @@ export default function ({ getService }: FtrProviderContext) {
aiAssistantManagementSelection: ['all', 'read', 'minimal_all', 'minimal_read'],
inventory: ['all', 'read', 'minimal_all', 'minimal_read'],
dataQuality: ['all', 'read', 'minimal_all', 'minimal_read'],
manageReporting: ['all', 'read', 'minimal_all', 'minimal_read'],
entityManager: ['all', 'read', 'minimal_all', 'minimal_read'],
},
global: ['all', 'read'],
@ -312,6 +313,7 @@ export default function ({ getService }: FtrProviderContext) {
infrastructure: ['all', 'read', 'minimal_all', 'minimal_read'],
logs: ['all', 'read', 'minimal_all', 'minimal_read'],
dataQuality: ['all', 'read', 'minimal_all', 'minimal_read'],
manageReporting: ['all', 'read', 'minimal_all', 'minimal_read'],
apm: ['all', 'read', 'minimal_all', 'minimal_read', 'settings_save'],
discover: [
'all',

View file

@ -171,6 +171,7 @@ export default function ({ getService }: FtrProviderContext) {
'osquery:telemetry-packs',
'osquery:telemetry-saved-queries',
'report:execute',
'report:execute-scheduled',
'risk_engine:risk_scoring',
'search:agentless-connectors-manager',
'security-solution-ea-asset-criticality-ecs-migration',

View file

@ -0,0 +1,167 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { SerializedSearchSourceFields } from '@kbn/data-plugin/common';
import { FtrProviderContext } from '../ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function ({ getService }: FtrProviderContext) {
const reportingAPI = getService('reportingAPI');
describe('Disable Scheduled Reports', () => {
const scheduledReportIds: string[] = [];
before(async () => {
await reportingAPI.initEcommerce();
});
after(async () => {
await reportingAPI.teardownEcommerce();
await reportingAPI.deleteAllReports();
await reportingAPI.deleteScheduledReports(scheduledReportIds);
await reportingAPI.deleteTasks(scheduledReportIds);
});
it('should allow reporting user to disable their own scheduled report', async () => {
const report = await reportingAPI.schedulePdf(
reportingAPI.REPORTING_USER_USERNAME,
reportingAPI.REPORTING_USER_PASSWORD,
{
browserTimezone: 'UTC',
title: 'test PDF allowed',
layout: { id: 'preserve_layout' },
locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }],
objectType: 'dashboard',
version: '7.14.0',
}
);
expect(report.status).to.eql(200);
const reportId = report.body.job.id;
scheduledReportIds.push(reportId);
// report created by reporting user, reporting user should be able to disable
const res = await reportingAPI.disableScheduledReports(
[reportId],
reportingAPI.REPORTING_USER_USERNAME,
reportingAPI.REPORTING_USER_PASSWORD
);
expect(res).to.eql({ scheduled_report_ids: [reportId], errors: [], total: 1 });
const soResult = await reportingAPI.getScheduledReports(reportId);
expect(soResult.body._source.scheduled_report.enabled).to.eql(false);
const taskResult = await reportingAPI.getTask(reportId);
expect(taskResult.body._source?.task.enabled).to.eql(false);
});
it('should not allow user to disable other users reports when no ManageReporting feature privilege', async () => {
const report = await reportingAPI.schedulePdf(
reportingAPI.MANAGE_REPORTING_USER_USERNAME,
reportingAPI.MANAGE_REPORTING_USER_PASSWORD,
{
browserTimezone: 'UTC',
title: 'test PDF allowed',
layout: { id: 'preserve_layout' },
locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }],
objectType: 'visualization',
version: '7.14.0',
}
);
expect(report.status).to.eql(200);
const reportId = report.body.job.id;
scheduledReportIds.push(reportId);
// report created by manage reporting user, reporting user should not be able to disable
const res = await reportingAPI.disableScheduledReports(
[reportId],
reportingAPI.REPORTING_USER_USERNAME,
reportingAPI.REPORTING_USER_PASSWORD
);
expect(res).to.eql({
scheduled_report_ids: [],
errors: [
{
message: `Not found.`,
status: 404,
id: reportId,
},
],
total: 1,
});
const soResult = await reportingAPI.getScheduledReports(reportId);
expect(soResult.body._source.scheduled_report.enabled).to.eql(true);
const taskResult = await reportingAPI.getTask(reportId);
expect(taskResult.body._source?.task.enabled).to.eql(true);
});
it('should allow user to disable other users reports when they have ManageReporting feature privilege', async () => {
const report1 = await reportingAPI.scheduleCsv(
{
browserTimezone: 'UTC',
title: 'allowed search',
objectType: 'search',
searchSource: {
version: true,
fields: [{ field: '*', include_unmapped: true }],
index: '5193f870-d861-11e9-a311-0fa548c5f953',
} as unknown as SerializedSearchSourceFields,
columns: [],
version: '7.13.0',
},
reportingAPI.REPORTING_USER_USERNAME,
reportingAPI.REPORTING_USER_PASSWORD
);
const report2 = await reportingAPI.schedulePdf(
reportingAPI.MANAGE_REPORTING_USER_USERNAME,
reportingAPI.MANAGE_REPORTING_USER_PASSWORD,
{
browserTimezone: 'UTC',
title: 'test PDF allowed',
layout: { id: 'preserve_layout' },
locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }],
objectType: 'visualization',
version: '7.14.0',
}
);
expect(report1.status).to.eql(200);
expect(report2.status).to.eql(200);
const report1Id = report1.body.job.id;
const report2Id = report2.body.job.id;
scheduledReportIds.push(report1Id);
scheduledReportIds.push(report2Id);
// manage reporting user should be able to disable their own report and reporting user report
const res = await reportingAPI.disableScheduledReports(
[report1Id, report2Id],
reportingAPI.MANAGE_REPORTING_USER_USERNAME,
reportingAPI.MANAGE_REPORTING_USER_PASSWORD
);
expect(res).to.eql({ scheduled_report_ids: [report1Id, report2Id], errors: [], total: 2 });
const soResult1 = await reportingAPI.getScheduledReports(report1Id);
expect(soResult1.body._source.scheduled_report.enabled).to.eql(false);
const soResult2 = await reportingAPI.getScheduledReports(report2Id);
expect(soResult2.body._source.scheduled_report.enabled).to.eql(false);
const taskResult1 = await reportingAPI.getTask(report1Id);
expect(taskResult1.body._source?.task.enabled).to.eql(false);
const taskResult2 = await reportingAPI.getTask(report2Id);
expect(taskResult2.body._source?.task.enabled).to.eql(false);
});
});
}

View file

@ -17,6 +17,8 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) {
await reportingAPI.createTestReportingUserRole();
await reportingAPI.createDataAnalyst();
await reportingAPI.createTestReportingUser();
await reportingAPI.createManageReportingUserRole();
await reportingAPI.createManageReportingUser();
});
loadTestFile(require.resolve('./bwc_existing_indexes'));
@ -25,6 +27,8 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./ilm_migration_apis'));
loadTestFile(require.resolve('./security_roles_privileges'));
loadTestFile(require.resolve('./spaces'));
loadTestFile(require.resolve('./list_scheduled_reports'));
loadTestFile(require.resolve('./disable_scheduled_reports'));
loadTestFile(require.resolve('./list_jobs'));
// CSV-specific

View file

@ -0,0 +1,131 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { SerializedSearchSourceFields } from '@kbn/data-plugin/common';
import { JobParamsPDFV2 } from '@kbn/reporting-export-types-pdf-common';
import { JobParamsCSV } from '@kbn/reporting-export-types-csv-common';
import { FtrProviderContext } from '../ftr_provider_context';
const pdfPayload: JobParamsPDFV2 = {
browserTimezone: 'UTC',
title: 'test PDF allowed',
layout: { id: 'preserve_layout' },
locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }],
objectType: 'dashboard',
version: '7.14.0',
};
const csvPayload: JobParamsCSV = {
browserTimezone: 'UTC',
title: 'allowed search',
objectType: 'search',
searchSource: {
version: true,
fields: [{ field: '*', include_unmapped: true }],
index: '5193f870-d861-11e9-a311-0fa548c5f953',
} as unknown as SerializedSearchSourceFields,
columns: [],
version: '7.13.0',
};
// eslint-disable-next-line import/no-default-export
export default function ({ getService }: FtrProviderContext) {
const reportingAPI = getService('reportingAPI');
describe('List Scheduled Reports', () => {
let report1Id: string;
let report2Id: string;
let report3Id: string;
const scheduledReportIds: string[] = [];
before(async () => {
await reportingAPI.initEcommerce();
const report1 = await reportingAPI.schedulePdf(
reportingAPI.REPORTING_USER_USERNAME,
reportingAPI.REPORTING_USER_PASSWORD,
pdfPayload
);
const report2 = await reportingAPI.scheduleCsv(
csvPayload,
reportingAPI.REPORTING_USER_USERNAME,
reportingAPI.REPORTING_USER_PASSWORD
);
const report3 = await reportingAPI.schedulePdf(
reportingAPI.MANAGE_REPORTING_USER_USERNAME,
reportingAPI.MANAGE_REPORTING_USER_PASSWORD,
pdfPayload
);
expect(report1.status).to.eql(200);
expect(report2.status).to.eql(200);
expect(report3.status).to.eql(200);
report1Id = report1.body.job.id;
report2Id = report2.body.job.id;
report3Id = report3.body.job.id;
scheduledReportIds.push(report1Id);
scheduledReportIds.push(report2Id);
scheduledReportIds.push(report3Id);
});
after(async () => {
await reportingAPI.teardownEcommerce();
await reportingAPI.deleteAllReports();
await reportingAPI.deleteScheduledReports(scheduledReportIds);
await reportingAPI.deleteTasks(scheduledReportIds);
});
it('should only return reports scheduled by the user when user does not have ManageReporting feature privilege', async () => {
const res = await reportingAPI.listScheduledReports(
reportingAPI.REPORTING_USER_USERNAME,
reportingAPI.REPORTING_USER_PASSWORD
);
expect(res.total).to.eql(2);
for (const report of res.data) {
expect(report.created_by).to.eql(reportingAPI.REPORTING_USER_USERNAME);
expect([report1Id, report2Id]).to.contain(report.id);
expect(report.next_run).not.to.be(undefined);
expect(report.space_id).to.eql('default');
expect(report.schedule).to.have.property('rrule');
expect(report.enabled).to.be(true);
expect(report.payload).to.have.property('objectType');
expect(report.payload).to.have.property('browserTimezone');
expect(report.payload).to.have.property('title');
}
});
it('should return reports scheduled by all users when user has ManageReporting feature privilege', async () => {
const res = await reportingAPI.listScheduledReports(
reportingAPI.MANAGE_REPORTING_USER_USERNAME,
reportingAPI.MANAGE_REPORTING_USER_PASSWORD
);
expect(res.total).to.eql(3);
for (const report of res.data) {
expect([
reportingAPI.REPORTING_USER_USERNAME,
reportingAPI.MANAGE_REPORTING_USER_USERNAME,
]).to.contain(report.created_by);
expect([report1Id, report2Id, report3Id]).to.contain(report.id);
expect(report.next_run).not.to.be(undefined);
expect(report.space_id).to.eql('default');
expect(report.schedule).to.have.property('rrule');
expect(report.enabled).to.be(true);
expect(report.payload).to.have.property('objectType');
expect(report.payload).to.have.property('browserTimezone');
expect(report.payload).to.have.property('title');
}
});
});
}

View file

@ -7,6 +7,8 @@
import expect from '@kbn/expect';
import { SerializedSearchSourceFields } from '@kbn/data-plugin/common';
import { SearchHit } from '@elastic/elasticsearch/lib/api/types';
import { SerializedConcreteTaskInstance } from '@kbn/task-manager-plugin/server/task';
import { FtrProviderContext } from '../ftr_provider_context';
// eslint-disable-next-line import/no-default-export
@ -14,13 +16,33 @@ export default function ({ getService }: FtrProviderContext) {
const reportingAPI = getService('reportingAPI');
const supertest = getService('supertest');
function testExpectedTask(
id: string,
jobtype: string,
task: SearchHit<{ task: SerializedConcreteTaskInstance }>
) {
expect(task._source?.task.taskType).to.eql('report:execute-scheduled');
const params = JSON.parse(task._source?.task.params ?? '');
expect(params.id).to.eql(id);
expect(params.jobtype).to.eql(jobtype);
expect(task._source?.task.apiKey).not.to.be(undefined);
expect(task._source?.task.schedule?.rrule).not.to.be(undefined);
expect(task._source?.task.schedule?.interval).to.be(undefined);
}
describe('Security Roles and Privileges for Applications', () => {
const scheduledReportIds: string[] = [];
const scheduledReportTaskIds: string[] = [];
before(async () => {
await reportingAPI.initEcommerce();
});
after(async () => {
await reportingAPI.teardownEcommerce();
await reportingAPI.deleteAllReports();
await reportingAPI.deleteScheduledReports(scheduledReportIds);
await reportingAPI.deleteTasks(scheduledReportTaskIds);
});
describe('Dashboard: Generate PDF report', () => {
@ -162,6 +184,185 @@ export default function ({ getService }: FtrProviderContext) {
});
});
describe('Dashboard: Schedule PDF report', () => {
it('does not allow user that does not have the role-based privilege', async () => {
const res = await reportingAPI.schedulePdf(
reportingAPI.DATA_ANALYST_USERNAME,
reportingAPI.DATA_ANALYST_PASSWORD,
{
browserTimezone: 'UTC',
title: 'test PDF disallowed',
layout: { id: 'preserve_layout' },
locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }],
objectType: 'dashboard',
version: '7.14.0',
}
);
expect(res.status).to.eql(403);
});
it('does allow user with the role-based privilege', async () => {
const res = await reportingAPI.schedulePdf(
reportingAPI.REPORTING_USER_USERNAME,
reportingAPI.REPORTING_USER_PASSWORD,
{
browserTimezone: 'UTC',
title: 'test PDF allowed',
layout: { id: 'preserve_layout' },
locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }],
objectType: 'dashboard',
version: '7.14.0',
}
);
expect(res.status).to.eql(200);
const soResult = await reportingAPI.getScheduledReports(res.body.job.id);
expect(soResult.status).to.eql(200);
expect(soResult.body._source.scheduled_report.title).to.eql('test PDF allowed');
scheduledReportIds.push(res.body.job.id);
const taskResult = await reportingAPI.getTask(res.body.job.id);
expect(taskResult.status).to.eql(200);
testExpectedTask(res.body.job.id, 'printable_pdf_v2', taskResult.body);
scheduledReportTaskIds.push(res.body.job.id);
});
});
describe('Visualize: Schedule PDF report', () => {
it('does not allow user that does not have the role-based privilege', async () => {
const res = await reportingAPI.schedulePdf(
reportingAPI.DATA_ANALYST_USERNAME,
reportingAPI.DATA_ANALYST_PASSWORD,
{
browserTimezone: 'UTC',
title: 'test PDF disallowed',
layout: { id: 'preserve_layout' },
locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }],
objectType: 'visualization',
version: '7.14.0',
}
);
expect(res.status).to.eql(403);
});
it('does allow user with the role-based privilege', async () => {
const res = await reportingAPI.schedulePdf(
reportingAPI.REPORTING_USER_USERNAME,
reportingAPI.REPORTING_USER_PASSWORD,
{
browserTimezone: 'UTC',
title: 'test PDF allowed',
layout: { id: 'preserve_layout' },
locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }],
objectType: 'visualization',
version: '7.14.0',
}
);
expect(res.status).to.eql(200);
const soResult = await reportingAPI.getScheduledReports(res.body.job.id);
expect(soResult.status).to.eql(200);
expect(soResult.body._source.scheduled_report.title).to.eql('test PDF allowed');
scheduledReportIds.push(res.body.job.id);
const taskResult = await reportingAPI.getTask(res.body.job.id);
expect(taskResult.status).to.eql(200);
testExpectedTask(res.body.job.id, 'printable_pdf_v2', taskResult.body);
scheduledReportTaskIds.push(res.body.job.id);
});
});
describe('Canvas: Schedule PDF report', () => {
it('does not allow user that does not have the role-based privilege', async () => {
const res = await reportingAPI.schedulePdf(
reportingAPI.DATA_ANALYST_USERNAME,
reportingAPI.DATA_ANALYST_PASSWORD,
{
browserTimezone: 'UTC',
title: 'test PDF disallowed',
layout: { id: 'preserve_layout' },
locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }],
objectType: 'canvas',
version: '7.14.0',
}
);
expect(res.status).to.eql(403);
});
it('does allow user with the role-based privilege', async () => {
const res = await reportingAPI.schedulePdf(
reportingAPI.REPORTING_USER_USERNAME,
reportingAPI.REPORTING_USER_PASSWORD,
{
browserTimezone: 'UTC',
title: 'test PDF allowed',
layout: { id: 'preserve_layout' },
locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }],
objectType: 'canvas',
version: '7.14.0',
}
);
expect(res.status).to.eql(200);
const soResult = await reportingAPI.getScheduledReports(res.body.job.id);
expect(soResult.status).to.eql(200);
expect(soResult.body._source.scheduled_report.title).to.eql('test PDF allowed');
scheduledReportIds.push(res.body.job.id);
const taskResult = await reportingAPI.getTask(res.body.job.id);
expect(taskResult.status).to.eql(200);
testExpectedTask(res.body.job.id, 'printable_pdf_v2', taskResult.body);
scheduledReportTaskIds.push(res.body.job.id);
});
});
describe('Discover: Schedule CSV report', () => {
it('does not allow user that does not have the role-based privilege', async () => {
const res = await reportingAPI.scheduleCsv(
{
browserTimezone: 'UTC',
searchSource: {} as SerializedSearchSourceFields,
objectType: 'search',
title: 'test disallowed',
version: '7.14.0',
},
reportingAPI.DATA_ANALYST_USERNAME,
reportingAPI.DATA_ANALYST_PASSWORD
);
expect(res.status).to.eql(403);
});
it('does allow user with the role-based privilege', async () => {
const res = await reportingAPI.scheduleCsv(
{
browserTimezone: 'UTC',
title: 'allowed search',
objectType: 'search',
searchSource: {
version: true,
fields: [{ field: '*', include_unmapped: true }],
index: '5193f870-d861-11e9-a311-0fa548c5f953',
} as unknown as SerializedSearchSourceFields,
columns: [],
version: '7.13.0',
},
reportingAPI.REPORTING_USER_USERNAME,
reportingAPI.REPORTING_USER_PASSWORD
);
expect(res.status).to.eql(200);
const soResult = await reportingAPI.getScheduledReports(res.body.job.id);
expect(soResult.status).to.eql(200);
expect(soResult.body._source.scheduled_report.title).to.eql('allowed search');
scheduledReportIds.push(res.body.job.id);
const taskResult = await reportingAPI.getTask(res.body.job.id);
expect(taskResult.status).to.eql(200);
testExpectedTask(res.body.job.id, 'csv_searchsource', taskResult.body);
scheduledReportTaskIds.push(res.body.job.id);
});
});
// This tests the same API as x-pack/test/api_integration/apis/security/privileges.ts, but it uses the non-deprecated config
it('should register reporting privileges with the security privileges API', async () => {
await supertest

View file

@ -17,5 +17,6 @@ export default function ({ loadTestFile, getService }: FtrProviderContext) {
});
loadTestFile(require.resolve('./csv/job_apis_csv'));
loadTestFile(require.resolve('./schedule'));
});
}

View file

@ -0,0 +1,47 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function ({ getService }: FtrProviderContext) {
const reportingAPI = getService('reportingAPI');
describe('Scheduled Reports', () => {
before(async () => {
await reportingAPI.initLogs();
});
after(async () => {
await reportingAPI.teardownLogs();
});
afterEach(async () => {
await reportingAPI.deleteAllReports();
});
it('should return error when scheduling reports', async () => {
const res = await reportingAPI.schedulePdf(
reportingAPI.REPORTING_USER_USERNAME,
reportingAPI.REPORTING_USER_PASSWORD,
{
browserTimezone: 'UTC',
title: 'test PDF allowed',
layout: { id: 'preserve_layout' },
locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }],
objectType: 'dashboard',
version: '7.14.0',
}
);
expect(res.status).to.eql(403);
expect(res.body.message).to.eql(
`Security and API keys must be enabled for scheduled reporting`
);
});
});
}

View file

@ -15,6 +15,8 @@ import {
REPORTING_DATA_STREAM_WILDCARD_WITH_LEGACY,
} from '@kbn/reporting-server';
import rison from '@kbn/rison';
import { ALERTING_CASES_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server';
import { RruleSchedule } from '@kbn/task-manager-plugin/server';
import { FtrProviderContext } from '../ftr_provider_context';
function removeWhitespace(str: string) {
@ -39,6 +41,9 @@ export function createScenarios({ getService }: Pick<FtrProviderContext, 'getSer
const REPORTING_USER_USERNAME = 'reporting_user';
const REPORTING_USER_PASSWORD = 'reporting_user-password';
const REPORTING_ROLE = 'test_reporting_user';
const MANAGE_REPORTING_USER_USERNAME = 'manage_reporting_user';
const MANAGE_REPORTING_USER_PASSWORD = 'manage_reporting_user-password';
const MANAGE_REPORTING_ROLE = 'manage_reporting_role';
const logTaskManagerHealth = async () => {
// Check task manager health for analyzing test failures. See https://github.com/elastic/kibana/issues/114946
@ -126,6 +131,36 @@ export function createScenarios({ getService }: Pick<FtrProviderContext, 'getSer
});
};
const createManageReportingUserRole = async () => {
await security.role.create(MANAGE_REPORTING_ROLE, {
metadata: {},
elasticsearch: {
cluster: [],
indices: [
{
names: ['ecommerce'],
privileges: ['read', 'view_index_metadata'],
allow_restricted_indices: false,
},
],
run_as: [],
},
kibana: [
{
base: [],
feature: {
manageReporting: ['all'],
dashboard: ['minimal_read', 'download_csv_report', 'generate_report'],
discover: ['minimal_read', 'generate_report'],
canvas: ['minimal_read', 'generate_report'],
visualize: ['minimal_read', 'generate_report'],
},
spaces: ['*'],
},
],
});
};
const createDataAnalyst = async () => {
await security.user.create('data_analyst', {
password: 'data_analyst-password',
@ -134,6 +169,14 @@ export function createScenarios({ getService }: Pick<FtrProviderContext, 'getSer
});
};
const createManageReportingUser = async () => {
await security.user.create(MANAGE_REPORTING_USER_USERNAME, {
password: MANAGE_REPORTING_USER_PASSWORD,
roles: [MANAGE_REPORTING_ROLE],
full_name: 'Manage Reporting User',
});
};
const createTestReportingUser = async () => {
await security.user.create(REPORTING_USER_USERNAME, {
password: REPORTING_USER_PASSWORD,
@ -156,6 +199,19 @@ export function createScenarios({ getService }: Pick<FtrProviderContext, 'getSer
.set('kbn-xsrf', 'xxx')
.send({ jobParams });
};
const schedulePdf = async (
username: string,
password: string,
job: JobParamsPDFV2,
schedule: RruleSchedule = { rrule: { freq: 1, interval: 1, tzid: 'UTC' } }
) => {
const jobParams = rison.encode(job);
return await supertestWithoutAuth
.post(`/internal/reporting/schedule/printablePdfV2`)
.auth(username, password)
.set('kbn-xsrf', 'xxx')
.send({ jobParams, schedule });
};
const generatePng = async (
username: string,
password: string,
@ -170,6 +226,19 @@ export function createScenarios({ getService }: Pick<FtrProviderContext, 'getSer
.set('kbn-xsrf', 'xxx')
.send({ jobParams });
};
const schedulePng = async (
username: string,
password: string,
job: JobParamsPNGV2,
schedule: RruleSchedule = { rrule: { freq: 1, interval: 1, tzid: 'UTC' } }
) => {
const jobParams = rison.encode(job);
return await supertestWithoutAuth
.post(`/internal/reporting/schedule/pngV2`)
.auth(username, password)
.set('kbn-xsrf', 'xxx')
.send({ jobParams, schedule });
};
const generateCsv = async (
job: JobParamsCSV,
username = 'elastic',
@ -184,6 +253,46 @@ export function createScenarios({ getService }: Pick<FtrProviderContext, 'getSer
.set('kbn-xsrf', 'xxx')
.send({ jobParams });
};
const scheduleCsv = async (
job: JobParamsCSV,
username = 'elastic',
password = process.env.TEST_KIBANA_PASS || 'changeme',
schedule: RruleSchedule = { rrule: { freq: 1, interval: 1, tzid: 'UTC' } }
) => {
const jobParams = rison.encode(job);
return await supertestWithoutAuth
.post(`/internal/reporting/schedule/csv_searchsource`)
.auth(username, password)
.set('kbn-xsrf', 'xxx')
.send({ jobParams, schedule });
};
const listScheduledReports = async (
username = 'elastic',
password = process.env.TEST_KIBANA_PASS || 'changeme'
) => {
const res = await supertestWithoutAuth
.get(INTERNAL_ROUTES.SCHEDULED.LIST)
.auth(username, password)
.set('kbn-xsrf', 'xxx');
return res.body;
};
const disableScheduledReports = async (
ids: string[],
username = 'elastic',
password = process.env.TEST_KIBANA_PASS || 'changeme'
) => {
const { body } = await supertestWithoutAuth
.patch(INTERNAL_ROUTES.SCHEDULED.BULK_DISABLE)
.auth(username, password)
.set('kbn-xsrf', 'xxx')
.send({ ids })
.expect(200);
return body;
};
const postJob = async (
apiPath: string,
@ -221,7 +330,6 @@ export function createScenarios({ getService }: Pick<FtrProviderContext, 'getSer
.get(`${INTERNAL_ROUTES.JOBS.LIST}?page=0&ids=${id}`)
.auth(username, password)
.set('kbn-xsrf', 'xxx')
.send()
.expect(200);
return job?.output?.error_code;
};
@ -290,6 +398,30 @@ export function createScenarios({ getService }: Pick<FtrProviderContext, 'getSer
.expect(200);
};
const getScheduledReports = async (id: string) => {
return await esSupertest.get(
`/${ALERTING_CASES_SAVED_OBJECT_INDEX}/_doc/scheduled_report:${id}`
);
};
const deleteScheduledReports = async (ids: string[]) => {
return await Promise.all(
ids.map((id) =>
esSupertest.delete(`/${ALERTING_CASES_SAVED_OBJECT_INDEX}/_doc/scheduled_report:${id}`)
)
);
};
const getTask = async (taskId: string) => {
return await esSupertest.get(`/.kibana_task_manager/_doc/task:${taskId}`);
};
const deleteTasks = async (ids: string[]) => {
return await Promise.all(
ids.map((id) => esSupertest.delete(`/.kibana_task_manager/_doc/task:${id}`))
);
};
return {
logTaskManagerHealth,
initEcommerce,
@ -301,13 +433,21 @@ export function createScenarios({ getService }: Pick<FtrProviderContext, 'getSer
REPORTING_USER_USERNAME,
REPORTING_USER_PASSWORD,
REPORTING_ROLE,
MANAGE_REPORTING_USER_USERNAME,
MANAGE_REPORTING_USER_PASSWORD,
MANAGE_REPORTING_ROLE,
createDataAnalystRole,
createDataAnalyst,
createTestReportingUserRole,
createTestReportingUser,
createManageReportingUserRole,
createManageReportingUser,
generatePdf,
generatePng,
generateCsv,
schedulePdf,
schedulePng,
scheduleCsv,
listReports,
postJob,
postJobJSON,
@ -317,5 +457,11 @@ export function createScenarios({ getService }: Pick<FtrProviderContext, 'getSer
migrateReportingIndices,
makeAllReportingIndicesUnmanaged,
getJobErrorCode,
getScheduledReports,
deleteScheduledReports,
getTask,
deleteTasks,
listScheduledReports,
disableScheduledReports,
};
}

View file

@ -69,6 +69,7 @@ export default function ({ getService }: FtrProviderContext) {
generalCases: 0,
generalCasesV2: 0,
generalCasesV3: 0,
manageReporting: 0,
maps: 2,
maps_v2: 2,
canvas: 2,

View file

@ -120,6 +120,18 @@ export default function ({ getService }: FtrProviderContext) {
"saved_object:search-session/delete",
"saved_object:search-session/bulk_delete",
"saved_object:search-session/share_to_space",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"saved_object:index-pattern/bulk_get",
"saved_object:index-pattern/get",
"saved_object:index-pattern/find",
@ -247,6 +259,18 @@ export default function ({ getService }: FtrProviderContext) {
"login:",
"api:downloadCsv",
"ui:management/insightsAndAlerting/reporting",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"ui:dashboard/downloadCsv",
"ui:dashboard_v2/downloadCsv",
],
@ -254,6 +278,18 @@ export default function ({ getService }: FtrProviderContext) {
"login:",
"api:generateReport",
"ui:management/insightsAndAlerting/reporting",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"ui:dashboard/generateScreenshot",
"ui:dashboard_v2/generateScreenshot",
],
@ -418,6 +454,18 @@ export default function ({ getService }: FtrProviderContext) {
"saved_object:url/delete",
"saved_object:url/bulk_delete",
"saved_object:url/share_to_space",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"ui:visualize/show",
"ui:visualize/delete",
"ui:visualize/save",
@ -765,6 +813,18 @@ export default function ({ getService }: FtrProviderContext) {
"saved_object:search-session/delete",
"saved_object:search-session/bulk_delete",
"saved_object:search-session/share_to_space",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"saved_object:index-pattern/bulk_get",
"saved_object:index-pattern/get",
"saved_object:index-pattern/find",
@ -873,12 +933,36 @@ export default function ({ getService }: FtrProviderContext) {
"login:",
"api:downloadCsv",
"ui:management/insightsAndAlerting/reporting",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"ui:dashboard_v2/downloadCsv",
],
"generate_report": Array [
"login:",
"api:generateReport",
"ui:management/insightsAndAlerting/reporting",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"ui:dashboard_v2/generateScreenshot",
],
"minimal_all": Array [
@ -1022,6 +1106,18 @@ export default function ({ getService }: FtrProviderContext) {
"saved_object:url/delete",
"saved_object:url/bulk_delete",
"saved_object:url/share_to_space",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"ui:visualize_v2/show",
"ui:visualize_v2/delete",
"ui:visualize_v2/save",
@ -1347,6 +1443,18 @@ export default function ({ getService }: FtrProviderContext) {
"saved_object:search-session/delete",
"saved_object:search-session/bulk_delete",
"saved_object:search-session/share_to_space",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"saved_object:index-pattern/bulk_get",
"saved_object:index-pattern/get",
"saved_object:index-pattern/find",
@ -1390,6 +1498,18 @@ export default function ({ getService }: FtrProviderContext) {
"login:",
"api:generateReport",
"ui:management/insightsAndAlerting/reporting",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"ui:discover/generateCsv",
"ui:discover_v2/generateCsv",
],
@ -1698,6 +1818,18 @@ export default function ({ getService }: FtrProviderContext) {
"saved_object:search-session/delete",
"saved_object:search-session/bulk_delete",
"saved_object:search-session/share_to_space",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"saved_object:index-pattern/bulk_get",
"saved_object:index-pattern/get",
"saved_object:index-pattern/find",
@ -1733,6 +1865,18 @@ export default function ({ getService }: FtrProviderContext) {
"login:",
"api:generateReport",
"ui:management/insightsAndAlerting/reporting",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"ui:discover_v2/generateCsv",
],
"minimal_all": Array [
@ -1983,6 +2127,18 @@ export default function ({ getService }: FtrProviderContext) {
"saved_object:cloud/close_point_in_time",
"api:downloadCsv",
"ui:management/insightsAndAlerting/reporting",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"ui:dashboard_v2/downloadCsv",
"api:generateReport",
"ui:discover_v2/generateCsv",
@ -2028,6 +2184,18 @@ export default function ({ getService }: FtrProviderContext) {
"saved_object:cloud/close_point_in_time",
"api:downloadCsv",
"ui:management/insightsAndAlerting/reporting",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"ui:dashboard_v2/downloadCsv",
"api:generateReport",
"ui:discover_v2/generateCsv",

View file

@ -1587,6 +1587,18 @@ export default function ({ getService }: FtrProviderContext) {
"saved_object:search-session/delete",
"saved_object:search-session/bulk_delete",
"saved_object:search-session/share_to_space",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"saved_object:index-pattern/bulk_get",
"saved_object:index-pattern/get",
"saved_object:index-pattern/find",
@ -1714,6 +1726,18 @@ export default function ({ getService }: FtrProviderContext) {
"login:",
"api:downloadCsv",
"ui:management/insightsAndAlerting/reporting",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"ui:dashboard/downloadCsv",
"ui:dashboard_v2/downloadCsv",
],
@ -1721,6 +1745,18 @@ export default function ({ getService }: FtrProviderContext) {
"login:",
"api:generateReport",
"ui:management/insightsAndAlerting/reporting",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"ui:dashboard/generateScreenshot",
"ui:dashboard_v2/generateScreenshot",
],
@ -1885,6 +1921,18 @@ export default function ({ getService }: FtrProviderContext) {
"saved_object:url/delete",
"saved_object:url/bulk_delete",
"saved_object:url/share_to_space",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"ui:visualize/show",
"ui:visualize/delete",
"ui:visualize/save",
@ -2232,6 +2280,18 @@ export default function ({ getService }: FtrProviderContext) {
"saved_object:search-session/delete",
"saved_object:search-session/bulk_delete",
"saved_object:search-session/share_to_space",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"saved_object:index-pattern/bulk_get",
"saved_object:index-pattern/get",
"saved_object:index-pattern/find",
@ -2340,12 +2400,36 @@ export default function ({ getService }: FtrProviderContext) {
"login:",
"api:downloadCsv",
"ui:management/insightsAndAlerting/reporting",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"ui:dashboard_v2/downloadCsv",
],
"generate_report": Array [
"login:",
"api:generateReport",
"ui:management/insightsAndAlerting/reporting",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"ui:dashboard_v2/generateScreenshot",
],
"minimal_all": Array [
@ -2489,6 +2573,18 @@ export default function ({ getService }: FtrProviderContext) {
"saved_object:url/delete",
"saved_object:url/bulk_delete",
"saved_object:url/share_to_space",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"ui:visualize_v2/show",
"ui:visualize_v2/delete",
"ui:visualize_v2/save",
@ -2814,6 +2910,18 @@ export default function ({ getService }: FtrProviderContext) {
"saved_object:search-session/delete",
"saved_object:search-session/bulk_delete",
"saved_object:search-session/share_to_space",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"saved_object:index-pattern/bulk_get",
"saved_object:index-pattern/get",
"saved_object:index-pattern/find",
@ -2857,6 +2965,18 @@ export default function ({ getService }: FtrProviderContext) {
"login:",
"api:generateReport",
"ui:management/insightsAndAlerting/reporting",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"ui:discover/generateCsv",
"ui:discover_v2/generateCsv",
],
@ -3165,6 +3285,18 @@ export default function ({ getService }: FtrProviderContext) {
"saved_object:search-session/delete",
"saved_object:search-session/bulk_delete",
"saved_object:search-session/share_to_space",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"saved_object:index-pattern/bulk_get",
"saved_object:index-pattern/get",
"saved_object:index-pattern/find",
@ -3200,6 +3332,18 @@ export default function ({ getService }: FtrProviderContext) {
"login:",
"api:generateReport",
"ui:management/insightsAndAlerting/reporting",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"ui:discover_v2/generateCsv",
],
"minimal_all": Array [
@ -6514,6 +6658,18 @@ export default function ({ getService }: FtrProviderContext) {
"saved_object:cloud/close_point_in_time",
"api:downloadCsv",
"ui:management/insightsAndAlerting/reporting",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"ui:dashboard_v2/downloadCsv",
"api:generateReport",
"ui:discover_v2/generateCsv",
@ -6559,6 +6715,18 @@ export default function ({ getService }: FtrProviderContext) {
"saved_object:cloud/close_point_in_time",
"api:downloadCsv",
"ui:management/insightsAndAlerting/reporting",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"ui:dashboard_v2/downloadCsv",
"api:generateReport",
"ui:discover_v2/generateCsv",

View file

@ -120,6 +120,18 @@ export default function ({ getService }: FtrProviderContext) {
"saved_object:search-session/delete",
"saved_object:search-session/bulk_delete",
"saved_object:search-session/share_to_space",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"saved_object:index-pattern/bulk_get",
"saved_object:index-pattern/get",
"saved_object:index-pattern/find",
@ -247,6 +259,18 @@ export default function ({ getService }: FtrProviderContext) {
"login:",
"api:downloadCsv",
"ui:management/insightsAndAlerting/reporting",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"ui:dashboard/downloadCsv",
"ui:dashboard_v2/downloadCsv",
],
@ -254,6 +278,18 @@ export default function ({ getService }: FtrProviderContext) {
"login:",
"api:generateReport",
"ui:management/insightsAndAlerting/reporting",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"ui:dashboard/generateScreenshot",
"ui:dashboard_v2/generateScreenshot",
],
@ -418,6 +454,18 @@ export default function ({ getService }: FtrProviderContext) {
"saved_object:url/delete",
"saved_object:url/bulk_delete",
"saved_object:url/share_to_space",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"ui:visualize/show",
"ui:visualize/delete",
"ui:visualize/save",
@ -765,6 +813,18 @@ export default function ({ getService }: FtrProviderContext) {
"saved_object:search-session/delete",
"saved_object:search-session/bulk_delete",
"saved_object:search-session/share_to_space",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"saved_object:index-pattern/bulk_get",
"saved_object:index-pattern/get",
"saved_object:index-pattern/find",
@ -873,12 +933,36 @@ export default function ({ getService }: FtrProviderContext) {
"login:",
"api:downloadCsv",
"ui:management/insightsAndAlerting/reporting",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"ui:dashboard_v2/downloadCsv",
],
"generate_report": Array [
"login:",
"api:generateReport",
"ui:management/insightsAndAlerting/reporting",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"ui:dashboard_v2/generateScreenshot",
],
"minimal_all": Array [
@ -1022,6 +1106,18 @@ export default function ({ getService }: FtrProviderContext) {
"saved_object:url/delete",
"saved_object:url/bulk_delete",
"saved_object:url/share_to_space",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"ui:visualize_v2/show",
"ui:visualize_v2/delete",
"ui:visualize_v2/save",
@ -1347,6 +1443,18 @@ export default function ({ getService }: FtrProviderContext) {
"saved_object:search-session/delete",
"saved_object:search-session/bulk_delete",
"saved_object:search-session/share_to_space",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"saved_object:index-pattern/bulk_get",
"saved_object:index-pattern/get",
"saved_object:index-pattern/find",
@ -1390,6 +1498,18 @@ export default function ({ getService }: FtrProviderContext) {
"login:",
"api:generateReport",
"ui:management/insightsAndAlerting/reporting",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"ui:discover/generateCsv",
"ui:discover_v2/generateCsv",
],
@ -1698,6 +1818,18 @@ export default function ({ getService }: FtrProviderContext) {
"saved_object:search-session/delete",
"saved_object:search-session/bulk_delete",
"saved_object:search-session/share_to_space",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"saved_object:index-pattern/bulk_get",
"saved_object:index-pattern/get",
"saved_object:index-pattern/find",
@ -1733,6 +1865,18 @@ export default function ({ getService }: FtrProviderContext) {
"login:",
"api:generateReport",
"ui:management/insightsAndAlerting/reporting",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"ui:discover_v2/generateCsv",
],
"minimal_all": Array [
@ -1983,6 +2127,18 @@ export default function ({ getService }: FtrProviderContext) {
"saved_object:cloud/close_point_in_time",
"api:downloadCsv",
"ui:management/insightsAndAlerting/reporting",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"ui:dashboard_v2/downloadCsv",
"api:generateReport",
"ui:discover_v2/generateCsv",
@ -2028,6 +2184,18 @@ export default function ({ getService }: FtrProviderContext) {
"saved_object:cloud/close_point_in_time",
"api:downloadCsv",
"ui:management/insightsAndAlerting/reporting",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"ui:dashboard_v2/downloadCsv",
"api:generateReport",
"ui:discover_v2/generateCsv",

View file

@ -89,6 +89,18 @@ export default function ({ getService }: FtrProviderContext) {
"saved_object:cloud/close_point_in_time",
"api:downloadCsv",
"ui:management/insightsAndAlerting/reporting",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"ui:dashboard_v2/downloadCsv",
"api:generateReport",
"ui:discover_v2/generateCsv",
@ -134,6 +146,18 @@ export default function ({ getService }: FtrProviderContext) {
"saved_object:cloud/close_point_in_time",
"api:downloadCsv",
"ui:management/insightsAndAlerting/reporting",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"ui:dashboard_v2/downloadCsv",
"api:generateReport",
"ui:discover_v2/generateCsv",
@ -879,6 +903,18 @@ export default function ({ getService }: FtrProviderContext) {
"saved_object:search-session/delete",
"saved_object:search-session/bulk_delete",
"saved_object:search-session/share_to_space",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"ui:discover_v2/show",
"ui:discover_v2/save",
"ui:discover_v2/createShortUrl",
@ -1766,6 +1802,18 @@ export default function ({ getService }: FtrProviderContext) {
"saved_object:search-session/delete",
"saved_object:search-session/bulk_delete",
"saved_object:search-session/share_to_space",
"saved_object:scheduled_report/bulk_get",
"saved_object:scheduled_report/get",
"saved_object:scheduled_report/find",
"saved_object:scheduled_report/open_point_in_time",
"saved_object:scheduled_report/close_point_in_time",
"saved_object:scheduled_report/create",
"saved_object:scheduled_report/bulk_create",
"saved_object:scheduled_report/update",
"saved_object:scheduled_report/bulk_update",
"saved_object:scheduled_report/delete",
"saved_object:scheduled_report/bulk_delete",
"saved_object:scheduled_report/share_to_space",
"ui:discover_v2/show",
"ui:discover_v2/save",
"ui:discover_v2/createShortUrl",