diff --git a/docs/settings/reporting-settings.asciidoc b/docs/settings/reporting-settings.asciidoc index 9bb11f3f99a15..084ac633e9bca 100644 --- a/docs/settings/reporting-settings.asciidoc +++ b/docs/settings/reporting-settings.asciidoc @@ -275,9 +275,20 @@ For information about {kib} memory limits, see <> setting. Defaults to `.reporting`. +| [[xpack-reporting-roles-enabled]] `xpack.reporting.roles.enabled` + | deprecated:[7.13.0,This setting must be set to `false` in 8.0.] When `true`, grants users + access to the {report-features} by assigning reporting roles, specified by `xpack.reporting.roles.allow`. + Granting access to users this way is deprecated. Set to `false` and use + {kibana-ref}/kibana-privileges.html[{kib} privileges] instead. + Defaults to `true`. + | `xpack.reporting.roles.allow` - | Specifies the roles in addition to superusers that can use reporting. - Defaults to `[ "reporting_user" ]`. + + | deprecated:[7.13.0,This setting will be removed in 8.0.] Specifies the roles, + in addition to superusers, that can generate reports, using the {ref}/security-api.html#security-role-apis[{es} role management APIs]. + Requires `xpack.reporting.roles.enabled` to be `true`. + Granting access to users this way is deprecated. Use + {kibana-ref}/kibana-privileges.html[{kib} privileges] instead. + Defaults to `[ "reporting_user" ]`. |=== diff --git a/docs/user/reporting/index.asciidoc b/docs/user/reporting/index.asciidoc index dbe433466c961..144ed1ea28c93 100644 --- a/docs/user/reporting/index.asciidoc +++ b/docs/user/reporting/index.asciidoc @@ -31,10 +31,15 @@ for different operating systems. [[reporting-required-privileges]] == Roles and privileges -To generate a report, you must have the `reporting_user` role. You also need -the appropriate {kib} privileges to access the objects that you -want to report on and the {es} indices. See <> -for an example. +When security is enabled, access to the {report-features} is controlled by security privileges. In versions 7.12 and earlier, you can grant access to the {report-features} +by assigning users the `reporting_user` role in {es}. In 7.13 and later, you can configure *Reporting* to use +<>. It is recommended that *Reporting* is configured to +use {kib} privileges by setting <> to `false`. By using {kib} privileges, you can define +custom roles that grant *Reporting* privileges as sub-features of {kib} applications in *Role Management*. + +Users must also have the {kib} privileges to access the saved objects and associated {es} indices included in the generated reports. +For an example, refer to <>. [float] [[manually-generate-reports]] diff --git a/docs/user/security/images/reporting-custom-role.png b/docs/user/security/images/reporting-custom-role.png new file mode 100644 index 0000000000000..4034ca3665806 Binary files /dev/null and b/docs/user/security/images/reporting-custom-role.png differ diff --git a/docs/user/security/reporting.asciidoc b/docs/user/security/reporting.asciidoc index e69643ef9712a..2f331e252c492 100644 --- a/docs/user/security/reporting.asciidoc +++ b/docs/user/security/reporting.asciidoc @@ -16,17 +16,30 @@ For more information, see //// [[reporting-app-users]] -To enable users to generate reports, you must assign them the built-in `reporting_user` -role. Users will also need the appropriate <> to access the objects -to report on and the {es} indices. +Access to reporting features is limited to privileged users. In older versions of Kibana, you could only grant +users the privilege by assigning them the `reporting_user` role in Elasticsearch. In 7.13 and above, you have +the option to create your own roles that grant access to reporting features using <>. + +It is recommended that you set `xpack.reporting.roles.enabled: false` in your kibana.yml to begin using Kibana +privileges. This will allow users to only see Reporting widgets in applications when they have privilege to use +them. + +[NOTE] +============================================================================ +The default value of `xpack.reporting.roles.enabled` is `true` for 7.x versions of Kibana. To migrate users to the +new method of securing access to *Reporting*, you must explicitly set `xpack.reporting.roles.enabled: false` in +`kibana.yml`. In the next major version of Kibana, having this set to `false` will be the only valid configuration. +============================================================================ + +This document discusses how to create a role that grants access to reporting features using the new method of +Kibana application privileges. [float] [[reporting-roles-management-ui]] -=== If you are using the `native` realm +=== Create the role in the `native` realm -To assign roles, use the *Roles* UI or <>. -This example shows how to use *Roles* page to create a user who has a custom role and the -`reporting_user` role. +To create roles, use the *Roles* UI or <>. This example shows how to +create a role that grants reporting feature privileges in {kib} applications. . Open the main menu, then click *Stack Management > Roles*. @@ -42,60 +55,69 @@ For more information, see {ref}/security-privileges.html[Security privileges]. [role="screenshot"] image::user/security/images/reporting-privileges-example.png["Reporting privileges"] -. Add space privileges. +. Add space privileges for the {kib} applications that allow access to the reporting options. ++ +To allow users to create CSV reports in *Discover*, or PDF reports in *Canvas*, +*Visualize Library*, and *Dashboard*, click *Add Kibana privilege* for each application, +then select the privileges to generate +reports. For example, select *All* privileges for all features, or *Customize* to grant +the privilege to generate reports for only specific applications. ++ +[role="screenshot"] +image::user/security/images/reporting-custom-role.png["Reporting custom role"] ++ +[NOTE] +============================================================================ +Granting users access to reporting features in any application also grants them access to manage their reports in *Stack Management > Reporting*. +============================================================================ + -Reporting users typically save searches, create -visualizations, and build dashboards. They require a space -that provides read and write privileges in -*Discover* and *Dashboard*. - . Save your new role. -. Open the main menu, then click *Stack Management > Users*, add a new user, and assign the user the built-in -`reporting_user` role and your new custom role, `custom_reporting_user`. - -[float] -==== With a custom index - -If you are using Reporting with a custom index, -the `xpack.reporting.index` setting should begin -with `.reporting-*`. The default {kib} system user has -`all` privileges against the `.reporting-*` pattern of indices. - -[source,js] -xpack.reporting.index: '.reporting-custom-index' - -If you use a different pattern for the `xpack.reporting.index` setting, -you must create a custom role with appropriate access to the index, similar -to the following: - -. Open the main menu, then click *Stack Management > Roles*. -. Click *Create role*, then name the role `custom-reporting-user`. -. Specify the custom index and assign it the `all` index privilege. -. Open the main menu, then click *Stack Management > Users* and create a new user with -the `kibana_system` role and the `custom-reporting-user` role. -. Configure {kib} to use the new account: -[source,js] -elasticsearch.username: 'custom_kibana_system' +. Open the main menu, then click *Stack Management > Users*, add a new user, and assign the user +your new `custom_reporting_user` role. [float] [[reporting-roles-user-api]] ==== With the user API -This example uses the {ref}/security-api-put-user.html[user API] to create a user who has the -`reporting_user` role and the `kibana_admin` role: +This example uses the {ref}/security-api-put-role.html[role API] to create a role that +grants the privilege to generate reports in *Canvas*, *Discover*, *Visualize Library*, and *Dashboard*. +This role is meant to be granted to users in combination with other roles that grant read access +to the data in {es}, and at least read access in the applications +where they'll generate reports. [source, sh] --------------------------------------------------------------- -POST /_security/user/reporter +POST /_security/role/custom_reporting_user { - "password" : "x-pack-test-password", - "roles" : ["kibana_admin", "reporting_user"], - "full_name" : "Reporting User" + metadata: {}, + elasticsearch: { cluster: [], indices: [], run_as: [] }, + kibana: [ + { + base: [], + feature: { + dashboard: [ + 'generate_report', <1> + 'download_csv_report' <2> + ], + discover: ['generate_report'], <3> + canvas: ['generate_report'], <4> + visualize: ['generate_report'], <5> + }, + spaces: ['*'], + } + ] } --------------------------------------------------------------- +// CONSOLE + +<1> Grants access to generate PNG and PDF reports in *Dashboard*. +<2> Grants access to download CSV files from saved search panels in *Dashboard*. +<3> Grants access to generate CSV reports from saved searches in *Discover*. +<4> Grants access to generate PDF reports in *Canvas*. +<5> Grants access to generate PNG and PDF reports in *Visualize Library*. [float] -=== If you are using an external identity provider +=== When using an external provider If you are using an external identity provider, such as LDAP or Active Directory, you can either assign @@ -113,6 +135,35 @@ reporting_user: - "cn=Bill Murray,dc=example,dc=com" -------------------------------------------------------------------------------- +[float] +=== With a custom index + +If you are using a custom index, +the `xpack.reporting.index` setting should begin +with `.reporting-*`. The default {kib} system user has +`all` privileges against the `.reporting-*` pattern of indices. + +[source,js] +xpack.reporting.index: '.reporting-custom-index' + +If you use a different pattern for the `xpack.reporting.index` setting, +you must create a custom `kibana_system` user with appropriate access to the index, similar +to the following: + +. Open the main menu, then click *Stack Management > Roles*. +. Click *Create role*, then name the role `custom-reporting-user`. +. Specify the custom index and assign it the `all` index privilege. +. Open the main menu, then click *Stack Management > Users* and create a new user with +the `kibana_system` role and the `custom-reporting-user` role. +. Configure {kib} to use the new account: +[source,js] +elasticsearch.username: 'custom_kibana_system' + +[NOTE] +============================================================================ +Setting a custom index for *Reporting* is not supported in the next major version of Kibana. +============================================================================ + [role="xpack"] [[securing-reporting]] === Secure the reporting endpoints diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker index 877be1afeebe8..97e3ab3bb5b12 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker @@ -273,6 +273,7 @@ kibana_vars=( xpack.reporting.queue.pollIntervalErrorMultiplier xpack.reporting.queue.timeout xpack.reporting.roles.allow + xpack.reporting.roles.enabled xpack.rollup.enabled xpack.ruleRegistry.unsafe.write.enabled xpack.searchprofiler.enabled diff --git a/x-pack/plugins/canvas/kibana.json b/x-pack/plugins/canvas/kibana.json index cff1a3e7fa8b7..6213ecb58347c 100644 --- a/x-pack/plugins/canvas/kibana.json +++ b/x-pack/plugins/canvas/kibana.json @@ -18,6 +18,7 @@ ], "optionalPlugins": [ "home", + "reporting", "usageCollection" ], "requiredBundles": [ diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/__stories__/share_menu.stories.tsx b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/__stories__/share_menu.stories.tsx index 6943195f03dad..bca96f3851e37 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/__stories__/share_menu.stories.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/__stories__/share_menu.stories.tsx @@ -12,6 +12,7 @@ import { ShareMenu } from '../share_menu.component'; storiesOf('components/WorkpadHeader/ShareMenu', module).add('default', () => ( { diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.component.tsx b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.component.tsx index 2cd545f5d65b3..0d2e877bebdfd 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.component.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.component.tsx @@ -28,6 +28,8 @@ export type OnCloseFn = (type: CloseTypes) => void; export type GetExportUrlFn = (type: ExportUrlTypes, layout: LayoutType) => string; export interface Props { + /** Flag to include the Reporting option only if Reporting is enabled */ + includeReporting: boolean; /** Handler to invoke when an export URL is copied to the clipboard. */ onCopy: OnCopyFn; /** Handler to invoke when an end product is exported. */ @@ -39,7 +41,12 @@ export interface Props { /** * The Menu for Exporting a Workpad from Canvas. */ -export const ShareMenu: FunctionComponent = ({ onCopy, onExport, getExportUrl }) => { +export const ShareMenu: FunctionComponent = ({ + includeReporting, + onCopy, + onExport, + getExportUrl, +}) => { const [showFlyout, setShowFlyout] = useState(false); const onClose = () => { @@ -73,16 +80,18 @@ export const ShareMenu: FunctionComponent = ({ onCopy, onExport, getExpor closePopover(); }, }, - { - name: strings.getShareDownloadPDFTitle(), - icon: 'document', - panel: { - id: 1, - title: strings.getShareDownloadPDFTitle(), - content: getPDFPanel(closePopover), - }, - 'data-test-subj': 'sharePanel-PDFReports', - }, + includeReporting + ? { + name: strings.getShareDownloadPDFTitle(), + icon: 'document', + panel: { + id: 1, + title: strings.getShareDownloadPDFTitle(), + content: getPDFPanel(closePopover), + }, + 'data-test-subj': 'sharePanel-PDFReports', + } + : false, { name: strings.getShareWebsiteTitle(), icon: , @@ -91,7 +100,7 @@ export const ShareMenu: FunctionComponent = ({ onCopy, onExport, getExpor closePopover(); }, }, - ], + ].filter(Boolean), }); const shareControl = (togglePopover: React.MouseEventHandler) => ( @@ -123,6 +132,7 @@ export const ShareMenu: FunctionComponent = ({ onCopy, onExport, getExpor }; ShareMenu.propTypes = { + includeReporting: PropTypes.bool.isRequired, onCopy: PropTypes.func.isRequired, onExport: PropTypes.func.isRequired, getExportUrl: PropTypes.func.isRequired, diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.ts b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.ts index a0448504db54b..47b5e755d439c 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.ts +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.ts @@ -46,6 +46,7 @@ export const ShareMenu = compose( withServices, withProps( ({ workpad, pageCount, services }: Props & WithServicesProps): ComponentProps => ({ + includeReporting: services.reporting.includeReporting(), getExportUrl: (type, layout) => { if (type === 'pdf') { const pdfUrl = getPdfUrl( diff --git a/x-pack/plugins/canvas/public/plugin.tsx b/x-pack/plugins/canvas/public/plugin.tsx index 486cd03eb9dd6..750b542116a75 100644 --- a/x-pack/plugins/canvas/public/plugin.tsx +++ b/x-pack/plugins/canvas/public/plugin.tsx @@ -7,6 +7,7 @@ import { BehaviorSubject } from 'rxjs'; import { ChartsPluginSetup, ChartsPluginStart } from 'src/plugins/charts/public'; +import { ReportingStart } from '../../reporting/public'; import { CoreSetup, CoreStart, @@ -49,6 +50,7 @@ export interface CanvasSetupDeps { export interface CanvasStartDeps { embeddable: EmbeddableStart; expressions: ExpressionsStart; + reporting?: ReportingStart; inspector: InspectorStart; uiActions: UiActionsStart; charts: ChartsPluginStart; diff --git a/x-pack/plugins/canvas/public/services/context.tsx b/x-pack/plugins/canvas/public/services/context.tsx index 3865d98caf2b3..4c18aa68fb51e 100644 --- a/x-pack/plugins/canvas/public/services/context.tsx +++ b/x-pack/plugins/canvas/public/services/context.tsx @@ -54,6 +54,7 @@ export const ServicesProvider: FC<{ notify: specifiedProviders.notify.getService(), platform: specifiedProviders.platform.getService(), navLink: specifiedProviders.navLink.getService(), + reporting: specifiedProviders.reporting.getService(), labs: specifiedProviders.labs.getService(), }; return {children}; diff --git a/x-pack/plugins/canvas/public/services/index.ts b/x-pack/plugins/canvas/public/services/index.ts index 9bfc41a782edc..1566d6f28085a 100644 --- a/x-pack/plugins/canvas/public/services/index.ts +++ b/x-pack/plugins/canvas/public/services/index.ts @@ -14,6 +14,7 @@ import { navLinkServiceFactory } from './nav_link'; import { embeddablesServiceFactory } from './embeddables'; import { expressionsServiceFactory } from './expressions'; import { labsServiceFactory } from './labs'; +import { reportingServiceFactory } from './reporting'; export { NotifyService } from './notify'; export { PlatformService } from './platform'; @@ -79,6 +80,7 @@ export const services = { notify: new CanvasServiceProvider(notifyServiceFactory), platform: new CanvasServiceProvider(platformServiceFactory), navLink: new CanvasServiceProvider(navLinkServiceFactory), + reporting: new CanvasServiceProvider(reportingServiceFactory), labs: new CanvasServiceProvider(labsServiceFactory), }; @@ -90,6 +92,7 @@ export interface CanvasServices { notify: ServiceFromProvider; platform: ServiceFromProvider; navLink: ServiceFromProvider; + reporting: ServiceFromProvider; labs: ServiceFromProvider; } @@ -117,4 +120,5 @@ export const { platform: platformService, navLink: navLinkService, expressions: expressionsService, + reporting: reportingService, } = services; diff --git a/x-pack/plugins/canvas/public/services/reporting.ts b/x-pack/plugins/canvas/public/services/reporting.ts new file mode 100644 index 0000000000000..3299363cd5c7f --- /dev/null +++ b/x-pack/plugins/canvas/public/services/reporting.ts @@ -0,0 +1,35 @@ +/* + * 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 { CanvasServiceFactory } from './'; + +export interface ReportingService { + includeReporting: () => boolean; +} + +export const reportingServiceFactory: CanvasServiceFactory = ( + _coreSetup, + coreStart, + _setupPlugins, + startPlugins +): ReportingService => { + const { reporting } = startPlugins; + if (!reporting) { + // Reporting is not enabled + return { includeReporting: () => false }; + } + + if (reporting.usesUiCapabilities()) { + // Canvas has declared Reporting as a subfeature with the `generatePdf` UI Capability + return { + includeReporting: () => coreStart.application.capabilities.canvas?.generatePdf === true, + }; + } + + // Reporting is enabled as an Elasticsearch feature (Legacy/Deprecated) + return { includeReporting: () => true }; +}; diff --git a/x-pack/plugins/canvas/public/services/stubs/index.ts b/x-pack/plugins/canvas/public/services/stubs/index.ts index 91bda2556284e..786582ed94bd2 100644 --- a/x-pack/plugins/canvas/public/services/stubs/index.ts +++ b/x-pack/plugins/canvas/public/services/stubs/index.ts @@ -8,6 +8,7 @@ import { CanvasServices, services } from '../'; import { embeddablesService } from './embeddables'; import { expressionsService } from './expressions'; +import { reportingService } from './reporting'; import { navLinkService } from './nav_link'; import { notifyService } from './notify'; import { labsService } from './labs'; @@ -16,6 +17,7 @@ import { platformService } from './platform'; export const stubs: CanvasServices = { embeddables: embeddablesService, expressions: expressionsService, + reporting: reportingService, navLink: navLinkService, notify: notifyService, platform: platformService, diff --git a/x-pack/plugins/canvas/public/services/stubs/reporting.ts b/x-pack/plugins/canvas/public/services/stubs/reporting.ts new file mode 100644 index 0000000000000..f257dd14543ec --- /dev/null +++ b/x-pack/plugins/canvas/public/services/stubs/reporting.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ReportingService } from '../reporting'; + +export const reportingService: ReportingService = { + includeReporting: () => true, +}; diff --git a/x-pack/plugins/canvas/server/feature.test.ts b/x-pack/plugins/canvas/server/feature.test.ts new file mode 100644 index 0000000000000..cd5f0a4b4dc01 --- /dev/null +++ b/x-pack/plugins/canvas/server/feature.test.ts @@ -0,0 +1,193 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ReportingStart } from '../../reporting/server/types'; +import { getCanvasFeature } from './feature'; + +let mockReportingPlugin: ReportingStart; +beforeEach(() => { + mockReportingPlugin = { + usesUiCapabilities: () => false, + }; +}); + +it('Provides a feature declaration ', () => { + expect(getCanvasFeature({ reporting: mockReportingPlugin })).toMatchInlineSnapshot(` + Object { + "app": Array [ + "canvas", + "kibana", + ], + "catalogue": Array [ + "canvas", + ], + "category": Object { + "euiIconType": "logoKibana", + "id": "kibana", + "label": "Analytics", + "order": 1000, + }, + "id": "canvas", + "management": Object {}, + "name": "Canvas", + "order": 300, + "privileges": Object { + "all": Object { + "app": Array [ + "canvas", + "kibana", + ], + "catalogue": Array [ + "canvas", + ], + "savedObject": Object { + "all": Array [ + "canvas-workpad", + "canvas-element", + ], + "read": Array [ + "index-pattern", + ], + }, + "ui": Array [ + "save", + "show", + ], + }, + "read": Object { + "app": Array [ + "canvas", + "kibana", + ], + "catalogue": Array [ + "canvas", + ], + "savedObject": Object { + "all": Array [], + "read": Array [ + "index-pattern", + "canvas-workpad", + "canvas-element", + ], + }, + "ui": Array [ + "show", + ], + }, + }, + "subFeatures": Array [], + } + `); +}); + +it(`Calls on Reporting whether to include Generate PDF as a sub-feature`, () => { + mockReportingPlugin = { + usesUiCapabilities: () => true, + }; + expect(getCanvasFeature({ reporting: mockReportingPlugin })).toMatchInlineSnapshot(` + Object { + "app": Array [ + "canvas", + "kibana", + ], + "catalogue": Array [ + "canvas", + ], + "category": Object { + "euiIconType": "logoKibana", + "id": "kibana", + "label": "Analytics", + "order": 1000, + }, + "id": "canvas", + "management": Object { + "insightsAndAlerting": Array [ + "reporting", + ], + }, + "name": "Canvas", + "order": 300, + "privileges": Object { + "all": Object { + "app": Array [ + "canvas", + "kibana", + ], + "catalogue": Array [ + "canvas", + ], + "savedObject": Object { + "all": Array [ + "canvas-workpad", + "canvas-element", + ], + "read": Array [ + "index-pattern", + ], + }, + "ui": Array [ + "save", + "show", + ], + }, + "read": Object { + "app": Array [ + "canvas", + "kibana", + ], + "catalogue": Array [ + "canvas", + ], + "savedObject": Object { + "all": Array [], + "read": Array [ + "index-pattern", + "canvas-workpad", + "canvas-element", + ], + }, + "ui": Array [ + "show", + ], + }, + }, + "subFeatures": Array [ + Object { + "name": "Reporting", + "privilegeGroups": Array [ + Object { + "groupType": "independent", + "privileges": Array [ + Object { + "api": Array [ + "generateReport", + ], + "id": "generate_report", + "includeIn": "all", + "management": Object { + "insightsAndAlerting": Array [ + "reporting", + ], + }, + "minimumLicense": "platinum", + "name": "Generate PDF reports", + "savedObject": Object { + "all": Array [], + "read": Array [], + }, + "ui": Array [ + "generatePdf", + ], + }, + ], + }, + ], + }, + ], + } + `); +}); diff --git a/x-pack/plugins/canvas/server/feature.ts b/x-pack/plugins/canvas/server/feature.ts new file mode 100644 index 0000000000000..33368a8020b1e --- /dev/null +++ b/x-pack/plugins/canvas/server/feature.ts @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; +import { KibanaFeatureConfig } from '../../features/common'; +import { ReportingSetup } from '../../reporting/server'; + +/* + * Register Canvas as a Kibana feature, + * with Reporting sub-feature integration (if enabled) + */ +export function getCanvasFeature(plugins: { reporting?: ReportingSetup }): KibanaFeatureConfig { + const includeReporting = plugins.reporting && plugins.reporting.usesUiCapabilities(); + + return { + id: 'canvas', + name: 'Canvas', + order: 300, + category: DEFAULT_APP_CATEGORIES.kibana, + app: ['canvas', 'kibana'], + management: { + ...(includeReporting ? { insightsAndAlerting: ['reporting'] } : {}), + }, + catalogue: ['canvas'], + privileges: { + all: { + app: ['canvas', 'kibana'], + catalogue: ['canvas'], + savedObject: { + all: ['canvas-workpad', 'canvas-element'], + read: ['index-pattern'], + }, + ui: ['save', 'show'], + }, + read: { + app: ['canvas', 'kibana'], + catalogue: ['canvas'], + savedObject: { + all: [], + read: ['index-pattern', 'canvas-workpad', 'canvas-element'], + }, + ui: ['show'], + }, + }, + subFeatures: [ + ...(includeReporting + ? ([ + { + name: i18n.translate('xpack.canvas.features.reporting.pdfFeatureName', { + defaultMessage: 'Reporting', + }), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'generate_report', + name: i18n.translate('xpack.canvas.features.reporting.pdf', { + defaultMessage: 'Generate PDF reports', + }), + includeIn: 'all', + management: { insightsAndAlerting: ['reporting'] }, + minimumLicense: 'platinum', + savedObject: { all: [], read: [] }, + api: ['generateReport'], + ui: ['generatePdf'], + }, + ], + }, + ], + }, + ] as const) + : []), + ], + }; +} diff --git a/x-pack/plugins/canvas/server/plugin.ts b/x-pack/plugins/canvas/server/plugin.ts index 345f6099009fc..c95d825fb9b0b 100644 --- a/x-pack/plugins/canvas/server/plugin.ts +++ b/x-pack/plugins/canvas/server/plugin.ts @@ -10,8 +10,9 @@ import { ExpressionsServerSetup } from 'src/plugins/expressions/server'; import { BfetchServerSetup } from 'src/plugins/bfetch/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { HomeServerPluginSetup } from 'src/plugins/home/server'; -import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; +import { ReportingSetup } from '../../reporting/server'; import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; +import { getCanvasFeature } from './feature'; import { initRoutes } from './routes'; import { registerCanvasUsageCollector } from './collectors'; import { loadSampleData } from './sample_data'; @@ -24,6 +25,7 @@ interface PluginsSetup { features: FeaturesPluginSetup; home: HomeServerPluginSetup; bfetch: BfetchServerSetup; + reporting?: ReportingSetup; usageCollection?: UsageCollectionSetup; } @@ -38,34 +40,7 @@ export class CanvasPlugin implements Plugin { coreSetup.savedObjects.registerType(workpadType); coreSetup.savedObjects.registerType(workpadTemplateType); - plugins.features.registerKibanaFeature({ - id: 'canvas', - name: 'Canvas', - order: 300, - category: DEFAULT_APP_CATEGORIES.kibana, - app: ['canvas', 'kibana'], - catalogue: ['canvas'], - privileges: { - all: { - app: ['canvas', 'kibana'], - catalogue: ['canvas'], - savedObject: { - all: ['canvas-workpad', 'canvas-element'], - read: ['index-pattern'], - }, - ui: ['save', 'show'], - }, - read: { - app: ['canvas', 'kibana'], - catalogue: ['canvas'], - savedObject: { - all: [], - read: ['index-pattern', 'canvas-workpad', 'canvas-element'], - }, - ui: ['show'], - }, - }, - }); + plugins.features.registerKibanaFeature(getCanvasFeature(plugins)); const canvasRouter = coreSetup.http.createRouter(); diff --git a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap index 5c259b4c7b72e..88712f2ac14c0 100644 --- a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap +++ b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap @@ -1,5 +1,461 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`buildOSSFeatures returns features excluding reporting subfeatures 1`] = ` +Array [ + Object { + "id": "discover", + "subFeatures": Array [ + Object { + "name": "Short URLs", + "privilegeGroups": Array [ + Object { + "groupType": "independent", + "privileges": Array [ + Object { + "id": "url_create", + "includeIn": "all", + "name": "Create Short URLs", + "savedObject": Object { + "all": Array [ + "url", + ], + "read": Array [], + }, + "ui": Array [ + "createShortUrl", + ], + }, + ], + }, + ], + }, + Object { + "name": "Store Search Sessions", + "privilegeGroups": Array [ + Object { + "groupType": "independent", + "privileges": Array [ + Object { + "api": Array [ + "store_search_session", + ], + "id": "store_search_session", + "includeIn": "all", + "management": Object { + "kibana": Array [ + "search_sessions", + ], + }, + "name": "Store Search Sessions", + "savedObject": Object { + "all": Array [ + "search-session", + ], + "read": Array [], + }, + "ui": Array [ + "storeSearchSession", + ], + }, + ], + }, + ], + }, + ], + }, + Object { + "id": "visualize", + "subFeatures": Array [ + Object { + "name": "Short URLs", + "privilegeGroups": Array [ + Object { + "groupType": "independent", + "privileges": Array [ + Object { + "id": "url_create", + "includeIn": "all", + "name": "Create Short URLs", + "savedObject": Object { + "all": Array [ + "url", + ], + "read": Array [], + }, + "ui": Array [ + "createShortUrl", + ], + }, + ], + }, + ], + }, + ], + }, + Object { + "id": "dashboard", + "subFeatures": Array [ + Object { + "name": "Short URLs", + "privilegeGroups": Array [ + Object { + "groupType": "independent", + "privileges": Array [ + Object { + "id": "url_create", + "includeIn": "all", + "name": "Create Short URLs", + "savedObject": Object { + "all": Array [ + "url", + ], + "read": Array [], + }, + "ui": Array [ + "createShortUrl", + ], + }, + ], + }, + ], + }, + Object { + "name": "Store Search Sessions", + "privilegeGroups": Array [ + Object { + "groupType": "independent", + "privileges": Array [ + Object { + "api": Array [ + "store_search_session", + ], + "id": "store_search_session", + "includeIn": "all", + "management": Object { + "kibana": Array [ + "search_sessions", + ], + }, + "name": "Store Search Sessions", + "savedObject": Object { + "all": Array [ + "search-session", + ], + "read": Array [], + }, + "ui": Array [ + "storeSearchSession", + ], + }, + ], + }, + ], + }, + ], + }, + Object { + "id": "dev_tools", + "subFeatures": undefined, + }, + Object { + "id": "advancedSettings", + "subFeatures": undefined, + }, + Object { + "id": "indexPatterns", + "subFeatures": undefined, + }, + Object { + "id": "savedObjectsManagement", + "subFeatures": undefined, + }, +] +`; + +exports[`buildOSSFeatures returns features including reporting subfeatures 1`] = ` +Array [ + Object { + "id": "discover", + "subFeatures": Array [ + Object { + "name": "Short URLs", + "privilegeGroups": Array [ + Object { + "groupType": "independent", + "privileges": Array [ + Object { + "id": "url_create", + "includeIn": "all", + "name": "Create Short URLs", + "savedObject": Object { + "all": Array [ + "url", + ], + "read": Array [], + }, + "ui": Array [ + "createShortUrl", + ], + }, + ], + }, + ], + }, + Object { + "name": "Store Search Sessions", + "privilegeGroups": Array [ + Object { + "groupType": "independent", + "privileges": Array [ + Object { + "api": Array [ + "store_search_session", + ], + "id": "store_search_session", + "includeIn": "all", + "management": Object { + "kibana": Array [ + "search_sessions", + ], + }, + "name": "Store Search Sessions", + "savedObject": Object { + "all": Array [ + "search-session", + ], + "read": Array [], + }, + "ui": Array [ + "storeSearchSession", + ], + }, + ], + }, + ], + }, + Object { + "name": "Reporting", + "privilegeGroups": Array [ + Object { + "groupType": "independent", + "privileges": Array [ + Object { + "api": Array [ + "generateReport", + ], + "id": "generate_report", + "includeIn": "all", + "management": Object { + "insightsAndAlerting": Array [ + "reporting", + ], + }, + "name": "Generate CSV reports", + "savedObject": Object { + "all": Array [], + "read": Array [], + }, + "ui": Array [ + "generateCsv", + ], + }, + ], + }, + ], + }, + ], + }, + Object { + "id": "visualize", + "subFeatures": Array [ + Object { + "name": "Short URLs", + "privilegeGroups": Array [ + Object { + "groupType": "independent", + "privileges": Array [ + Object { + "id": "url_create", + "includeIn": "all", + "name": "Create Short URLs", + "savedObject": Object { + "all": Array [ + "url", + ], + "read": Array [], + }, + "ui": Array [ + "createShortUrl", + ], + }, + ], + }, + ], + }, + Object { + "name": "Reporting", + "privilegeGroups": Array [ + Object { + "groupType": "independent", + "privileges": Array [ + Object { + "api": Array [ + "generateReport", + ], + "id": "generate_report", + "includeIn": "all", + "management": Object { + "insightsAndAlerting": Array [ + "reporting", + ], + }, + "minimumLicense": "platinum", + "name": "Generate PDF or PNG reports", + "savedObject": Object { + "all": Array [], + "read": Array [], + }, + "ui": Array [ + "generateScreenshot", + ], + }, + ], + }, + ], + }, + ], + }, + Object { + "id": "dashboard", + "subFeatures": Array [ + Object { + "name": "Short URLs", + "privilegeGroups": Array [ + Object { + "groupType": "independent", + "privileges": Array [ + Object { + "id": "url_create", + "includeIn": "all", + "name": "Create Short URLs", + "savedObject": Object { + "all": Array [ + "url", + ], + "read": Array [], + }, + "ui": Array [ + "createShortUrl", + ], + }, + ], + }, + ], + }, + Object { + "name": "Store Search Sessions", + "privilegeGroups": Array [ + Object { + "groupType": "independent", + "privileges": Array [ + Object { + "api": Array [ + "store_search_session", + ], + "id": "store_search_session", + "includeIn": "all", + "management": Object { + "kibana": Array [ + "search_sessions", + ], + }, + "name": "Store Search Sessions", + "savedObject": Object { + "all": Array [ + "search-session", + ], + "read": Array [], + }, + "ui": Array [ + "storeSearchSession", + ], + }, + ], + }, + ], + }, + Object { + "name": "Reporting", + "privilegeGroups": Array [ + Object { + "groupType": "independent", + "privileges": Array [ + Object { + "api": Array [ + "generateReport", + ], + "id": "generate_report", + "includeIn": "all", + "management": Object { + "insightsAndAlerting": Array [ + "reporting", + ], + }, + "minimumLicense": "platinum", + "name": "Generate PDF or PNG reports", + "savedObject": Object { + "all": Array [], + "read": Array [], + }, + "ui": Array [ + "generateScreenshot", + ], + }, + Object { + "api": Array [ + "downloadCsv", + ], + "id": "download_csv_report", + "includeIn": "all", + "management": Object { + "insightsAndAlerting": Array [ + "reporting", + ], + }, + "name": "Download CSV reports from Saved Search panels", + "savedObject": Object { + "all": Array [], + "read": Array [], + }, + "ui": Array [ + "downloadCsv", + ], + }, + ], + }, + ], + }, + ], + }, + Object { + "id": "dev_tools", + "subFeatures": undefined, + }, + Object { + "id": "advancedSettings", + "subFeatures": undefined, + }, + Object { + "id": "indexPatterns", + "subFeatures": undefined, + }, + Object { + "id": "savedObjectsManagement", + "subFeatures": undefined, + }, +] +`; + exports[`buildOSSFeatures with a basic license returns the advancedSettings feature augmented with appropriate sub feature privileges 1`] = ` Array [ Object { diff --git a/x-pack/plugins/features/server/mocks.ts b/x-pack/plugins/features/server/mocks.ts index aa92694050766..7b10a185dd0db 100644 --- a/x-pack/plugins/features/server/mocks.ts +++ b/x-pack/plugins/features/server/mocks.ts @@ -14,6 +14,7 @@ const createSetup = (): jest.Mocked => { getFeaturesUICapabilities: jest.fn(), registerKibanaFeature: jest.fn(), registerElasticsearchFeature: jest.fn(), + enableReportingUiCapabilities: jest.fn(), }; }; diff --git a/x-pack/plugins/features/server/oss_features.test.ts b/x-pack/plugins/features/server/oss_features.test.ts index b86fa726b3050..86705cae6d5a6 100644 --- a/x-pack/plugins/features/server/oss_features.test.ts +++ b/x-pack/plugins/features/server/oss_features.test.ts @@ -14,7 +14,11 @@ import { LicenseType } from '../../licensing/server'; describe('buildOSSFeatures', () => { it('returns features including timelion', () => { expect( - buildOSSFeatures({ savedObjectTypes: ['foo', 'bar'], includeTimelion: true }).map((f) => f.id) + buildOSSFeatures({ + savedObjectTypes: ['foo', 'bar'], + includeTimelion: true, + includeReporting: false, + }).map((f) => f.id) ).toMatchInlineSnapshot(` Array [ "discover", @@ -31,9 +35,11 @@ Array [ it('returns features excluding timelion', () => { expect( - buildOSSFeatures({ savedObjectTypes: ['foo', 'bar'], includeTimelion: false }).map( - (f) => f.id - ) + buildOSSFeatures({ + savedObjectTypes: ['foo', 'bar'], + includeTimelion: false, + includeReporting: false, + }).map((f) => f.id) ).toMatchInlineSnapshot(` Array [ "discover", @@ -47,7 +53,31 @@ Array [ `); }); - const features = buildOSSFeatures({ savedObjectTypes: ['foo', 'bar'], includeTimelion: true }); + it('returns features including reporting subfeatures', () => { + expect( + buildOSSFeatures({ + savedObjectTypes: ['foo', 'bar'], + includeTimelion: false, + includeReporting: true, + }).map(({ id, subFeatures }) => ({ id, subFeatures })) + ).toMatchSnapshot(); + }); + + it('returns features excluding reporting subfeatures', () => { + expect( + buildOSSFeatures({ + savedObjectTypes: ['foo', 'bar'], + includeTimelion: false, + includeReporting: false, + }).map(({ id, subFeatures }) => ({ id, subFeatures })) + ).toMatchSnapshot(); + }); + + const features = buildOSSFeatures({ + savedObjectTypes: ['foo', 'bar'], + includeTimelion: true, + includeReporting: false, + }); features.forEach((featureConfig) => { (['enterprise', 'basic'] as LicenseType[]).forEach((licenseType) => { describe(`with a ${licenseType} license`, () => { diff --git a/x-pack/plugins/features/server/oss_features.ts b/x-pack/plugins/features/server/oss_features.ts index 91839e511a1ad..d1e96b5a788ec 100644 --- a/x-pack/plugins/features/server/oss_features.ts +++ b/x-pack/plugins/features/server/oss_features.ts @@ -6,15 +6,20 @@ */ import { i18n } from '@kbn/i18n'; -import { KibanaFeatureConfig } from '../common'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; +import type { KibanaFeatureConfig, SubFeatureConfig } from '../common'; export interface BuildOSSFeaturesParams { savedObjectTypes: string[]; includeTimelion: boolean; + includeReporting: boolean; } -export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSSFeaturesParams) => { +export const buildOSSFeatures = ({ + savedObjectTypes, + includeTimelion, + includeReporting, +}: BuildOSSFeaturesParams): KibanaFeatureConfig[] => { return [ { id: 'discover', @@ -23,6 +28,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS }), management: { kibana: ['search_sessions'], + ...(includeReporting ? { insightsAndAlerting: ['reporting'] } : {}), }, order: 100, category: DEFAULT_APP_CATEGORIES.kibana, @@ -107,6 +113,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS }, ], }, + ...(includeReporting ? [reportingFeatures.discoverReporting] : []), ], }, { @@ -114,6 +121,9 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS name: i18n.translate('xpack.features.visualizeFeatureName', { defaultMessage: 'Visualize Library', }), + management: { + ...(includeReporting ? { insightsAndAlerting: ['reporting'] } : {}), + }, order: 700, category: DEFAULT_APP_CATEGORIES.kibana, app: ['visualize', 'lens', 'kibana'], @@ -166,6 +176,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS }, ], }, + ...(includeReporting ? [reportingFeatures.visualizeReporting] : []), ], }, { @@ -175,6 +186,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS }), management: { kibana: ['search_sessions'], + ...(includeReporting ? { insightsAndAlerting: ['reporting'] } : {}), }, order: 200, category: DEFAULT_APP_CATEGORIES.kibana, @@ -279,6 +291,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS }, ], }, + ...(includeReporting ? [reportingFeatures.dashboardReporting] : []), ], }, { @@ -468,3 +481,99 @@ const timelionFeature: KibanaFeatureConfig = { }, }, }; + +const reportingPrivilegeGroupName = i18n.translate( + 'xpack.features.ossFeatures.reporting.reportingTitle', + { + defaultMessage: 'Reporting', + } +); + +const reportingFeatures: { + discoverReporting: SubFeatureConfig; + dashboardReporting: SubFeatureConfig; + visualizeReporting: SubFeatureConfig; +} = { + discoverReporting: { + name: reportingPrivilegeGroupName, + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'generate_report', + name: i18n.translate('xpack.features.ossFeatures.reporting.discoverGenerateCSV', { + defaultMessage: 'Generate CSV reports', + }), + includeIn: 'all', + savedObject: { all: [], read: [] }, + management: { insightsAndAlerting: ['reporting'] }, + api: ['generateReport'], + ui: ['generateCsv'], + }, + ], + }, + ], + }, + dashboardReporting: { + name: reportingPrivilegeGroupName, + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'generate_report', + name: i18n.translate( + 'xpack.features.ossFeatures.reporting.dashboardGenerateScreenshot', + { + defaultMessage: 'Generate PDF or PNG reports', + } + ), + includeIn: 'all', + minimumLicense: 'platinum', + savedObject: { all: [], read: [] }, + management: { insightsAndAlerting: ['reporting'] }, + api: ['generateReport'], + ui: ['generateScreenshot'], + }, + { + id: 'download_csv_report', + name: i18n.translate('xpack.features.ossFeatures.reporting.dashboardDownloadCSV', { + defaultMessage: 'Download CSV reports from Saved Search panels', + }), + includeIn: 'all', + savedObject: { all: [], read: [] }, + management: { insightsAndAlerting: ['reporting'] }, + api: ['downloadCsv'], + ui: ['downloadCsv'], + }, + ], + }, + ], + }, + visualizeReporting: { + name: reportingPrivilegeGroupName, + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'generate_report', + name: i18n.translate( + 'xpack.features.ossFeatures.reporting.visualizeGenerateScreenshot', + { + defaultMessage: 'Generate PDF or PNG reports', + } + ), + includeIn: 'all', + minimumLicense: 'platinum', + savedObject: { all: [], read: [] }, + management: { insightsAndAlerting: ['reporting'] }, + api: ['generateReport'], + ui: ['generateScreenshot'], + }, + ], + }, + ], + }, +}; diff --git a/x-pack/plugins/features/server/plugin.ts b/x-pack/plugins/features/server/plugin.ts index 6a9fd1da826a6..09a5b78ad868a 100644 --- a/x-pack/plugins/features/server/plugin.ts +++ b/x-pack/plugins/features/server/plugin.ts @@ -46,6 +46,14 @@ export interface PluginSetupContract { * */ getElasticsearchFeatures(): ElasticsearchFeature[]; getFeaturesUICapabilities(): UICapabilities; + + /* + * In the future, OSS features should register their own subfeature + * privileges. This can be done when parts of Reporting are moved to + * src/plugins. For now, this method exists for `reporting` to tell + * `features` to include Reporting when registering OSS features. + */ + enableReportingUiCapabilities(): void; } export interface PluginStartContract { @@ -66,6 +74,7 @@ export class FeaturesPlugin private readonly logger: Logger; private readonly featureRegistry: FeatureRegistry = new FeatureRegistry(); private isTimelionEnabled: boolean = false; + private isReportingEnabled: boolean = false; constructor(private readonly initializerContext: PluginInitializerContext) { this.logger = this.initializerContext.logger.get(); @@ -100,6 +109,7 @@ export class FeaturesPlugin this.featureRegistry ), getFeaturesUICapabilities, + enableReportingUiCapabilities: this.enableReportingUiCapabilities.bind(this), }); } @@ -128,10 +138,18 @@ export class FeaturesPlugin const features = buildOSSFeatures({ savedObjectTypes, includeTimelion: this.isTimelionEnabled, + includeReporting: this.isReportingEnabled, }); for (const feature of features) { this.featureRegistry.registerKibanaFeature(feature); } } + + private enableReportingUiCapabilities() { + this.logger.debug( + `Feature controls for Reporting plugin are enabled. Please assign access to Reporting use Kibana feature controls for applications.` + ); + this.isReportingEnabled = true; + } } diff --git a/x-pack/plugins/reporting/public/index.ts b/x-pack/plugins/reporting/public/index.ts index ee9db75da1523..7c82fda7554d4 100644 --- a/x-pack/plugins/reporting/public/index.ts +++ b/x-pack/plugins/reporting/public/index.ts @@ -18,6 +18,7 @@ export interface ReportingSetup { }; getDefaultLayoutSelectors: typeof getDefaultLayoutSelectors; ReportingAPIClient: typeof ReportingAPIClient; + usesUiCapabilities: () => boolean; } export type ReportingStart = ReportingSetup; diff --git a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.test.ts b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.test.ts index 06d626a4c4044..dbd0421fdf9b0 100644 --- a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.test.ts +++ b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.test.ts @@ -5,10 +5,11 @@ * 2.0. */ -import { of } from 'rxjs'; +import * as Rx from 'rxjs'; import { first } from 'rxjs/operators'; +import { CoreStart } from 'src/core/public'; import { LicensingPluginSetup } from '../../../licensing/public'; -import { GetCsvReportPanelAction } from './get_csv_panel_action'; +import { ReportingCsvPanelAction } from './get_csv_panel_action'; type LicenseResults = 'valid' | 'invalid' | 'unavailable' | 'expired'; @@ -17,6 +18,8 @@ describe('GetCsvReportPanelAction', () => { let context: any; let mockLicense$: any; let mockSearchSource: any; + let mockStartServicesPayload: [CoreStart, object, unknown]; + let mockStartServices$: Rx.Subject; beforeAll(() => { if (typeof window.URL.revokeObjectURL === 'undefined') { @@ -30,11 +33,20 @@ describe('GetCsvReportPanelAction', () => { beforeEach(() => { mockLicense$ = (state: LicenseResults = 'valid') => { - return (of({ + return (Rx.of({ check: jest.fn().mockImplementation(() => ({ state })), }) as unknown) as LicensingPluginSetup['license$']; }; + mockStartServices$ = new Rx.Subject<[CoreStart, object, unknown]>(); + mockStartServicesPayload = [ + ({ + application: { capabilities: { dashboard: { downloadCsv: true } } }, + } as unknown) as CoreStart, + {}, + null, + ]; + core = { http: { post: jest.fn().mockImplementation(() => Promise.resolve(true)), @@ -78,7 +90,14 @@ describe('GetCsvReportPanelAction', () => { }); it('translates empty embeddable context into job params', async () => { - const panel = new GetCsvReportPanelAction(core, mockLicense$()); + const panel = new ReportingCsvPanelAction({ + core, + license$: mockLicense$(), + startServices$: mockStartServices$, + usesUiCapabilities: true, + }); + + mockStartServices$.next(mockStartServicesPayload); await panel.execute(context); @@ -91,7 +110,6 @@ describe('GetCsvReportPanelAction', () => { }); it('translates embeddable context into job params', async () => { - // setup mockSearchSource = { createCopy: () => mockSearchSource, removeField: jest.fn(), @@ -106,9 +124,15 @@ describe('GetCsvReportPanelAction', () => { }; }; - const panel = new GetCsvReportPanelAction(core, mockLicense$()); + const panel = new ReportingCsvPanelAction({ + core, + license$: mockLicense$(), + startServices$: mockStartServices$, + usesUiCapabilities: true, + }); + + mockStartServices$.next(mockStartServicesPayload); - // test await panel.execute(context); expect(core.http.post).toHaveBeenCalledWith( @@ -121,7 +145,14 @@ describe('GetCsvReportPanelAction', () => { }); it('allows downloading for valid licenses', async () => { - const panel = new GetCsvReportPanelAction(core, mockLicense$()); + const panel = new ReportingCsvPanelAction({ + core, + license$: mockLicense$(), + startServices$: mockStartServices$, + usesUiCapabilities: true, + }); + + mockStartServices$.next(mockStartServicesPayload); await panel.execute(context); @@ -129,7 +160,14 @@ describe('GetCsvReportPanelAction', () => { }); it('shows a good old toastie when it successfully starts', async () => { - const panel = new GetCsvReportPanelAction(core, mockLicense$()); + const panel = new ReportingCsvPanelAction({ + core, + license$: mockLicense$(), + startServices$: mockStartServices$, + usesUiCapabilities: true, + }); + + mockStartServices$.next(mockStartServicesPayload); await panel.execute(context); @@ -144,7 +182,14 @@ describe('GetCsvReportPanelAction', () => { post: jest.fn().mockImplementation(() => Promise.reject('No more ram!')), }, }; - const panel = new GetCsvReportPanelAction(coreFails, mockLicense$()); + const panel = new ReportingCsvPanelAction({ + core: coreFails, + license$: mockLicense$(), + startServices$: mockStartServices$, + usesUiCapabilities: true, + }); + + mockStartServices$.next(mockStartServicesPayload); await panel.execute(context); @@ -152,15 +197,76 @@ describe('GetCsvReportPanelAction', () => { }); it(`doesn't allow downloads with bad licenses`, async () => { - const licenseMock = mockLicense$('invalid'); - const plugin = new GetCsvReportPanelAction(core, licenseMock); - await licenseMock.pipe(first()).toPromise(); + const licenseMock$ = mockLicense$('invalid'); + const plugin = new ReportingCsvPanelAction({ + core, + license$: licenseMock$, + startServices$: mockStartServices$, + usesUiCapabilities: true, + }); + + mockStartServices$.next(mockStartServicesPayload); + + await licenseMock$.pipe(first()).toPromise(); + expect(await plugin.isCompatible(context)).toEqual(false); }); it('sets a display and icon type', () => { - const panel = new GetCsvReportPanelAction(core, mockLicense$()); + const panel = new ReportingCsvPanelAction({ + core, + license$: mockLicense$(), + startServices$: mockStartServices$, + usesUiCapabilities: true, + }); + + mockStartServices$.next(mockStartServicesPayload); + expect(panel.getIconType()).toMatchInlineSnapshot(`"document"`); expect(panel.getDisplayName()).toMatchInlineSnapshot(`"Download CSV"`); }); + + describe('Application UI Capabilities', () => { + it(`doesn't allow downloads when UI capability is not enabled`, async () => { + const plugin = new ReportingCsvPanelAction({ + core, + license$: mockLicense$(), + startServices$: mockStartServices$, + usesUiCapabilities: true, + }); + + mockStartServices$.next([ + ({ application: { capabilities: {} } } as unknown) as CoreStart, + {}, + null, + ]); + + expect(await plugin.isCompatible(context)).toEqual(false); + }); + + it(`allows downloads when license is valid and UI capability is enabled`, async () => { + mockStartServices$ = new Rx.Subject(); + const plugin = new ReportingCsvPanelAction({ + core, + license$: mockLicense$(), + startServices$: mockStartServices$, + usesUiCapabilities: true, + }); + + mockStartServices$.next(mockStartServicesPayload); + + expect(await plugin.isCompatible(context)).toEqual(true); + }); + + it(`allows download when license is valid and deprecated roles config is enabled`, async () => { + const plugin = new ReportingCsvPanelAction({ + core, + license$: mockLicense$(), + startServices$: mockStartServices$, + usesUiCapabilities: false, + }); + + expect(await plugin.isCompatible(context)).toEqual(true); + }); + }); }); diff --git a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx index 95d193880975c..8a863e1ceaa65 100644 --- a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx +++ b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx @@ -7,7 +7,9 @@ import { i18n } from '@kbn/i18n'; import moment from 'moment-timezone'; +import * as Rx from 'rxjs'; import type { CoreSetup } from 'src/core/public'; +import { CoreStart } from 'src/core/public'; import type { ISearchEmbeddable, SavedSearch } from '../../../../../src/plugins/discover/public'; import { loadSharingDataHelpers, @@ -32,22 +34,38 @@ interface ActionContext { embeddable: ISearchEmbeddable; } -export class GetCsvReportPanelAction implements ActionDefinition { +interface Params { + core: CoreSetup; + startServices$: Rx.Observable<[CoreStart, object, unknown]>; + license$: LicensingPluginSetup['license$']; + usesUiCapabilities: boolean; +} + +export class ReportingCsvPanelAction implements ActionDefinition { private isDownloading: boolean; public readonly type = ''; public readonly id = CSV_REPORTING_ACTION; - private canDownloadCSV: boolean = false; + private licenseHasDownloadCsv: boolean = false; + private capabilityHasDownloadCsv: boolean = false; private core: CoreSetup; - constructor(core: CoreSetup, license$: LicensingPluginSetup['license$']) { + constructor({ core, startServices$, license$, usesUiCapabilities }: Params) { this.isDownloading = false; this.core = core; license$.subscribe((license) => { const results = license.check('reporting', 'basic'); const { showLinks } = checkLicense(results); - this.canDownloadCSV = showLinks; + this.licenseHasDownloadCsv = showLinks; }); + + if (usesUiCapabilities) { + startServices$.subscribe(([{ application }]) => { + this.capabilityHasDownloadCsv = application.capabilities.dashboard?.downloadCsv === true; + }); + } else { + this.capabilityHasDownloadCsv = true; // deprecated + } } public getIconType() { @@ -70,7 +88,7 @@ export class GetCsvReportPanelAction implements ActionDefinition } public isCompatible = async (context: ActionContext) => { - if (!this.canDownloadCSV) { + if (!this.licenseHasDownloadCsv || !this.capabilityHasDownloadCsv) { return false; } @@ -82,7 +100,7 @@ export class GetCsvReportPanelAction implements ActionDefinition public execute = async (context: ActionContext) => { const { embeddable } = context; - if (!isSavedSearchEmbeddable(embeddable)) { + if (!isSavedSearchEmbeddable(embeddable) || !(await this.isCompatible(context))) { throw new IncompatibleActionError(); } @@ -93,6 +111,10 @@ export class GetCsvReportPanelAction implements ActionDefinition const savedSearch = embeddable.getSavedSearch(); const { columns, searchSource } = await this.getSearchSource(savedSearch, embeddable); + // If the TZ is set to the default "Browser", it will not be useful for + // server-side export. We need to derive the timezone and pass it as a param + // to the export API. + // TODO: create a helper utility in Reporting. This is repeated in a few places. const kibanaTimezone = this.core.uiSettings.get('dateFormat:tz'); const browserTimezone = kibanaTimezone === 'Browser' ? moment.tz.guess() : kibanaTimezone; const immediateJobParams: JobParamsDownloadCSV = { diff --git a/x-pack/plugins/reporting/public/plugin.ts b/x-pack/plugins/reporting/public/plugin.ts index 435291e76ac49..ff0d425faf54a 100644 --- a/x-pack/plugins/reporting/public/plugin.ts +++ b/x-pack/plugins/reporting/public/plugin.ts @@ -35,17 +35,13 @@ import { } from './components'; import { ReportingAPIClient } from './lib/reporting_api_client'; import { ReportingNotifierStreamHandler as StreamHandler } from './lib/stream_handler'; -import { GetCsvReportPanelAction } from './panel_actions/get_csv_panel_action'; -import { csvReportingProvider } from './share_context_menu/register_csv_reporting'; -import { reportingPDFPNGProvider } from './share_context_menu/register_pdf_png_reporting'; +import { ReportingCsvPanelAction } from './panel_actions/get_csv_panel_action'; +import { ReportingCsvShareProvider } from './share_context_menu/register_csv_reporting'; +import { reportingScreenshotShareProvider } from './share_context_menu/register_pdf_png_reporting'; export interface ClientConfigType { - poll: { - jobsRefresh: { - interval: number; - intervalErrorMultiplier: number; - }; - }; + poll: { jobsRefresh: { interval: number; intervalErrorMultiplier: number } }; + roles: { enabled: boolean }; } function getStored(): JobId[] { @@ -90,11 +86,7 @@ export class ReportingPublicPlugin ReportingPublicPluginSetupDendencies, ReportingPublicPluginStartDendencies > { - private readonly contract: ReportingStart = { - components: { ScreenCapturePanel }, - getDefaultLayoutSelectors, - ReportingAPIClient, - }; + private readonly contract: ReportingStart; private readonly stop$ = new Rx.ReplaySubject(1); private readonly title = i18n.translate('xpack.reporting.management.reportingTitle', { defaultMessage: 'Reporting', @@ -106,22 +98,30 @@ export class ReportingPublicPlugin constructor(initializerContext: PluginInitializerContext) { this.config = initializerContext.config.get(); + + this.contract = { + ReportingAPIClient, + components: { ScreenCapturePanel }, + getDefaultLayoutSelectors, + usesUiCapabilities: () => this.config.roles?.enabled === false, + }; } - public setup( - core: CoreSetup, - { home, management, licensing, uiActions, share }: ReportingPublicPluginSetupDendencies - ) { + public setup(core: CoreSetup, setupDeps: ReportingPublicPluginSetupDendencies) { + const { http, notifications, getStartServices, uiSettings } = core; + const { toasts } = notifications; const { - http, - notifications: { toasts }, - getStartServices, - uiSettings, - } = core; - const { license$ } = licensing; + home, + management, + licensing: { license$ }, + share, + uiActions, + } = setupDeps; + + const startServices$ = Rx.from(getStartServices()); + const usesUiCapabilities = !this.config.roles.enabled; const apiClient = new ReportingAPIClient(http); - const action = new GetCsvReportPanelAction(core, license$); home.featureCatalogue.register({ id: 'reporting', @@ -136,6 +136,7 @@ export class ReportingPublicPlugin showOnHomePage: false, category: FeatureCatalogueCategory.ADMIN, }); + management.sections.section.insightsAndAlerting.registerApp({ id: 'reporting', title: this.title, @@ -157,15 +158,29 @@ export class ReportingPublicPlugin }, }); - uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, action); + uiActions.addTriggerAction( + CONTEXT_MENU_TRIGGER, + new ReportingCsvPanelAction({ core, startServices$, license$, usesUiCapabilities }) + ); - share.register(csvReportingProvider({ apiClient, toasts, license$, uiSettings })); share.register( - reportingPDFPNGProvider({ + ReportingCsvShareProvider({ + apiClient, + toasts, + license$, + startServices$, + uiSettings, + usesUiCapabilities, + }) + ); + share.register( + reportingScreenshotShareProvider({ apiClient, toasts, license$, + startServices$, uiSettings, + usesUiCapabilities, }) ); diff --git a/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx b/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx index 8995ef4739b09..9d26c69e57297 100644 --- a/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx +++ b/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx @@ -8,7 +8,9 @@ import { i18n } from '@kbn/i18n'; import moment from 'moment-timezone'; import React from 'react'; +import * as Rx from 'rxjs'; import type { IUiSettingsClient, ToastsSetup } from 'src/core/public'; +import { CoreStart } from 'src/core/public'; import type { SearchSourceFields } from 'src/plugins/data/common'; import type { ShareContext } from '../../../../../src/plugins/share/public'; import type { LicensingPluginSetup } from '../../../licensing/public'; @@ -18,34 +20,46 @@ import { ReportingPanelContent } from '../components/reporting_panel_content_laz import { checkLicense } from '../lib/license_check'; import type { ReportingAPIClient } from '../lib/reporting_api_client'; -interface ReportingProvider { - apiClient: ReportingAPIClient; - toasts: ToastsSetup; - license$: LicensingPluginSetup['license$']; - uiSettings: IUiSettingsClient; -} - -export const csvReportingProvider = ({ +export const ReportingCsvShareProvider = ({ apiClient, toasts, license$, + startServices$, uiSettings, -}: ReportingProvider) => { - let toolTipContent = ''; - let disabled = true; - let hasCSVReporting = false; + usesUiCapabilities, +}: { + apiClient: ReportingAPIClient; + toasts: ToastsSetup; + license$: LicensingPluginSetup['license$']; + startServices$: Rx.Observable<[CoreStart, object, unknown]>; + uiSettings: IUiSettingsClient; + usesUiCapabilities: boolean; +}) => { + let licenseToolTipContent = ''; + let licenseHasCsvReporting = false; + let licenseDisabled = true; + let capabilityHasCsvReporting = false; license$.subscribe((license) => { - const { enableLinks, showLinks, message } = checkLicense(license.check('reporting', 'basic')); - - toolTipContent = message; - hasCSVReporting = showLinks; - disabled = !enableLinks; + const licenseCheck = checkLicense(license.check('reporting', 'basic')); + licenseToolTipContent = licenseCheck.message; + licenseHasCsvReporting = licenseCheck.showLinks; + licenseDisabled = !licenseCheck.enableLinks; }); + if (usesUiCapabilities) { + startServices$.subscribe(([{ application }]) => { + // TODO: add abstractions in ExportTypeRegistry to use here? + capabilityHasCsvReporting = application.capabilities.discover?.generateCsv === true; + }); + } else { + capabilityHasCsvReporting = true; // deprecated + } + // If the TZ is set to the default "Browser", it will not be useful for // server-side export. We need to derive the timezone and pass it as a param // to the export API. + // TODO: create a helper utility in Reporting. This is repeated in a few places. const browserTimezone = uiSettings.get('dateFormat:tz') === 'Browser' ? moment.tz.guess() @@ -74,7 +88,7 @@ export const csvReportingProvider = ({ const shareActions = []; - if (hasCSVReporting) { + if (licenseHasCsvReporting && capabilityHasCsvReporting) { const panelTitle = i18n.translate('xpack.reporting.shareContextMenu.csvReportsButtonLabel', { defaultMessage: 'CSV Reports', }); @@ -83,8 +97,8 @@ export const csvReportingProvider = ({ shareMenuItem: { name: panelTitle, icon: 'document', - toolTipContent, - disabled, + toolTipContent: licenseToolTipContent, + disabled: licenseDisabled, ['data-test-subj']: 'csvReportMenuItem', sortOrder: 1, }, diff --git a/x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx b/x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx index 00ba167c50ae6..f4a952ef58298 100644 --- a/x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx +++ b/x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx @@ -8,7 +8,9 @@ import { i18n } from '@kbn/i18n'; import moment from 'moment-timezone'; import React from 'react'; +import * as Rx from 'rxjs'; import type { IUiSettingsClient, ToastsSetup } from 'src/core/public'; +import { CoreStart } from 'src/core/public'; import type { ShareContext } from '../../../../../src/plugins/share/public'; import type { LicensingPluginSetup } from '../../../licensing/public'; import type { LayoutParams } from '../../common/types'; @@ -18,34 +20,100 @@ import { ScreenCapturePanelContent } from '../components/screen_capture_panel_co import { checkLicense } from '../lib/license_check'; import type { ReportingAPIClient } from '../lib/reporting_api_client'; -interface ReportingPDFPNGProvider { +interface JobParamsProviderOptions { + shareableUrl: string; apiClient: ReportingAPIClient; - toasts: ToastsSetup; - license$: LicensingPluginSetup['license$']; - uiSettings: IUiSettingsClient; + objectType: string; + browserTimezone: string; + sharingData: Record; } -export const reportingPDFPNGProvider = ({ +const jobParamsProvider = ({ + objectType, + browserTimezone, + sharingData, +}: JobParamsProviderOptions) => { + return { + objectType, + browserTimezone, + layout: sharingData.layout as LayoutParams, + title: sharingData.title as string, + }; +}; + +const getPdfJobParams = (opts: JobParamsProviderOptions) => (): JobParamsPDF => { + // Relative URL must have URL prefix (Spaces ID prefix), but not server basePath + // Replace hashes with original RISON values. + const relativeUrl = opts.shareableUrl.replace( + window.location.origin + opts.apiClient.getServerBasePath(), + '' + ); + + return { + ...jobParamsProvider(opts), + relativeUrls: [relativeUrl], // multi URL for PDF + }; +}; + +const getPngJobParams = (opts: JobParamsProviderOptions) => (): JobParamsPNG => { + // Replace hashes with original RISON values. + const relativeUrl = opts.shareableUrl.replace( + window.location.origin + opts.apiClient.getServerBasePath(), + '' + ); + + return { + ...jobParamsProvider(opts), + relativeUrl, // single URL for PNG + }; +}; + +export const reportingScreenshotShareProvider = ({ apiClient, toasts, license$, + startServices$, uiSettings, -}: ReportingPDFPNGProvider) => { - let toolTipContent = ''; - let disabled = true; - let hasPDFPNGReporting = false; + usesUiCapabilities, +}: { + apiClient: ReportingAPIClient; + toasts: ToastsSetup; + license$: LicensingPluginSetup['license$']; + startServices$: Rx.Observable<[CoreStart, object, unknown]>; + uiSettings: IUiSettingsClient; + usesUiCapabilities: boolean; +}) => { + let licenseToolTipContent = ''; + let licenseDisabled = true; + let licenseHasScreenshotReporting = false; + let capabilityHasDashboardScreenshotReporting = false; + let capabilityHasVisualizeScreenshotReporting = false; license$.subscribe((license) => { const { enableLinks, showLinks, message } = checkLicense(license.check('reporting', 'gold')); - - toolTipContent = message; - hasPDFPNGReporting = showLinks; - disabled = !enableLinks; + licenseToolTipContent = message; + licenseHasScreenshotReporting = showLinks; + licenseDisabled = !enableLinks; }); + if (usesUiCapabilities) { + startServices$.subscribe(([{ application }]) => { + // TODO: add abstractions in ExportTypeRegistry to use here? + capabilityHasDashboardScreenshotReporting = + application.capabilities.dashboard?.generateScreenshot === true; + capabilityHasVisualizeScreenshotReporting = + application.capabilities.visualize?.generateScreenshot === true; + }); + } else { + // deprecated + capabilityHasDashboardScreenshotReporting = true; + capabilityHasVisualizeScreenshotReporting = true; + } + // If the TZ is set to the default "Browser", it will not be useful for // server-side export. We need to derive the timezone and pass it as a param // to the export API. + // TODO: create a helper utility in Reporting. This is repeated in a few places. const browserTimezone = uiSettings.get('dateFormat:tz') === 'Browser' ? moment.tz.guess() @@ -59,124 +127,100 @@ export const reportingPDFPNGProvider = ({ onClose, shareableUrl, }: ShareContext) => { - if (!['dashboard', 'visualization'].includes(objectType)) { + if (!licenseHasScreenshotReporting) { return []; } - // Dashboard only mode does not currently support reporting - // https://github.com/elastic/kibana/issues/18286 - // @TODO For NP - if (objectType === 'dashboard' && false) { + + if (!['dashboard', 'visualization'].includes(objectType)) { return []; } - const getPdfJobParams = (): JobParamsPDF => { - // Relative URL must have URL prefix (Spaces ID prefix), but not server basePath - // Replace hashes with original RISON values. - const relativeUrl = shareableUrl.replace( - window.location.origin + apiClient.getServerBasePath(), - '' - ); - - return { - objectType, - browserTimezone, - relativeUrls: [relativeUrl], // multi URL for PDF - layout: sharingData.layout as LayoutParams, - title: sharingData.title as string, - }; - }; + if (objectType === 'dashboard' && !capabilityHasDashboardScreenshotReporting) { + return []; + } - const getPngJobParams = (): JobParamsPNG => { - // Replace hashes with original RISON values. - const relativeUrl = shareableUrl.replace( - window.location.origin + apiClient.getServerBasePath(), - '' - ); - - return { - objectType, - browserTimezone, - relativeUrl, // single URL for PNG - layout: sharingData.layout as LayoutParams, - title: sharingData.title as string, - }; - }; + if (objectType === 'visualize' && !capabilityHasVisualizeScreenshotReporting) { + return []; + } const shareActions = []; - if (hasPDFPNGReporting) { - const pngPanelTitle = i18n.translate( - 'xpack.reporting.shareContextMenu.pngReportsButtonLabel', - { - defaultMessage: 'PNG Reports', - } - ); - - const pdfPanelTitle = i18n.translate( - 'xpack.reporting.shareContextMenu.pdfReportsButtonLabel', - { - defaultMessage: 'PDF Reports', - } - ); - - shareActions.push({ - shareMenuItem: { - name: pngPanelTitle, - icon: 'document', - toolTipContent, - disabled, - ['data-test-subj']: 'pngReportMenuItem', - sortOrder: 10, - }, - panel: { - id: 'reportingPngPanel', - title: pngPanelTitle, - content: ( - - ), - }, - }); - - shareActions.push({ - shareMenuItem: { - name: pdfPanelTitle, - icon: 'document', - toolTipContent, - disabled, - ['data-test-subj']: 'pdfReportMenuItem', - sortOrder: 10, - }, - panel: { - id: 'reportingPdfPanel', - title: pdfPanelTitle, - content: ( - - ), - }, - }); - } + const pngPanelTitle = i18n.translate('xpack.reporting.shareContextMenu.pngReportsButtonLabel', { + defaultMessage: 'PNG Reports', + }); + + const panelPng = { + shareMenuItem: { + name: pngPanelTitle, + icon: 'document', + toolTipContent: licenseToolTipContent, + disabled: licenseDisabled, + ['data-test-subj']: 'pngReportMenuItem', + sortOrder: 10, + }, + panel: { + id: 'reportingPngPanel', + title: pngPanelTitle, + content: ( + + ), + }, + }; + const pdfPanelTitle = i18n.translate('xpack.reporting.shareContextMenu.pdfReportsButtonLabel', { + defaultMessage: 'PDF Reports', + }); + + const panelPdf = { + shareMenuItem: { + name: pdfPanelTitle, + icon: 'document', + toolTipContent: licenseToolTipContent, + disabled: licenseDisabled, + ['data-test-subj']: 'pdfReportMenuItem', + sortOrder: 10, + }, + panel: { + id: 'reportingPdfPanel', + title: pdfPanelTitle, + content: ( + + ), + }, + }; + + shareActions.push(panelPng); + shareActions.push(panelPdf); return shareActions; }; - return { - id: 'screenCaptureReports', - getShareMenuItems, - }; + return { id: 'screenCaptureReports', getShareMenuItems }; }; diff --git a/x-pack/plugins/reporting/server/config/create_config.test.ts b/x-pack/plugins/reporting/server/config/create_config.test.ts index 6dfe890e173a1..e57528e5be490 100644 --- a/x-pack/plugins/reporting/server/config/create_config.test.ts +++ b/x-pack/plugins/reporting/server/config/create_config.test.ts @@ -104,6 +104,9 @@ describe('Reporting server createConfig$', () => { "pollInterval": 3000, "timeout": 120000, }, + "roles": Object { + "enabled": false, + }, } `); expect((mockLogger.warn as any).mock.calls.length).toBe(0); diff --git a/x-pack/plugins/reporting/server/config/index.test.ts b/x-pack/plugins/reporting/server/config/index.test.ts index a395cd23288eb..cba64500575aa 100644 --- a/x-pack/plugins/reporting/server/config/index.test.ts +++ b/x-pack/plugins/reporting/server/config/index.test.ts @@ -32,7 +32,7 @@ const applyReportingDeprecations = (settings: Record = {}) => { describe('deprecations', () => { ['.foo', '.reporting'].forEach((index) => { it('logs a warning if index is set', () => { - const { messages } = applyReportingDeprecations({ index }); + const { messages } = applyReportingDeprecations({ index, roles: { enabled: false } }); expect(messages).toMatchInlineSnapshot(` Array [ "\\"xpack.reporting.index\\" is deprecated. Multitenancy by changing \\"kibana.index\\" will not be supported starting in 8.0. See https://ela.st/kbn-remove-legacy-multitenancy for more details", @@ -40,4 +40,18 @@ describe('deprecations', () => { `); }); }); + + it('logs a warning if roles.enabled: true is set', () => { + const { messages } = applyReportingDeprecations({ roles: { enabled: true } }); + expect(messages).toMatchInlineSnapshot(` + Array [ + "\\"xpack.reporting.roles\\" is deprecated. Granting reporting privilege through a \\"reporting_user\\" role will not be supported starting in 8.0. Please set 'xpack.reporting.roles.enabled' to 'false' and grant reporting privilege to users through feature controls in Management > Security > Roles", + ] + `); + }); + + it('does not log a warning if roles.enabled: false is set', () => { + const { messages } = applyReportingDeprecations({ roles: { enabled: false } }); + expect(messages).toMatchInlineSnapshot(`Array []`); + }); }); diff --git a/x-pack/plugins/reporting/server/config/index.ts b/x-pack/plugins/reporting/server/config/index.ts index 4b97dbc1e2a84..cdd395037a410 100644 --- a/x-pack/plugins/reporting/server/config/index.ts +++ b/x-pack/plugins/reporting/server/config/index.ts @@ -7,14 +7,13 @@ import { PluginConfigDescriptor } from 'kibana/server'; import { get } from 'lodash'; - import { ConfigSchema, ReportingConfigType } from './schema'; export { buildConfig } from './config'; export { registerUiSettings } from './ui_settings'; export { ConfigSchema, ReportingConfigType }; export const config: PluginConfigDescriptor = { - exposeToBrowser: { poll: true }, + exposeToBrowser: { poll: true, roles: true }, schema: ConfigSchema, deprecations: ({ unused }) => [ unused('capture.browser.chromium.maxScreenshotDimension'), @@ -31,6 +30,16 @@ export const config: PluginConfigDescriptor = { message: `"${fromPath}.index" is deprecated. Multitenancy by changing "kibana.index" will not be supported starting in 8.0. See https://ela.st/kbn-remove-legacy-multitenancy for more details`, }); } + + if (reporting?.roles?.enabled !== false) { + addDeprecation({ + message: + `"${fromPath}.roles" is deprecated. Granting reporting privilege through a "reporting_user" role will not be supported ` + + `starting in 8.0. Please set 'xpack.reporting.roles.enabled' to 'false' and grant reporting privilege to users ` + + `through feature controls in Management > Security > Roles`, + }); + } + return settings; }, ], diff --git a/x-pack/plugins/reporting/server/config/schema.test.ts b/x-pack/plugins/reporting/server/config/schema.test.ts index 49e740b4f2683..e299db2405125 100644 --- a/x-pack/plugins/reporting/server/config/schema.test.ts +++ b/x-pack/plugins/reporting/server/config/schema.test.ts @@ -107,6 +107,7 @@ describe('Reporting Config Schema', () => { "allow": Array [ "reporting_user", ], + "enabled": true, }, } `); @@ -211,6 +212,7 @@ describe('Reporting Config Schema', () => { "allow": Array [ "reporting_user", ], + "enabled": true, }, } `); diff --git a/x-pack/plugins/reporting/server/config/schema.ts b/x-pack/plugins/reporting/server/config/schema.ts index 3f901b283f7bd..f56bf5520072b 100644 --- a/x-pack/plugins/reporting/server/config/schema.ts +++ b/x-pack/plugins/reporting/server/config/schema.ts @@ -160,6 +160,7 @@ const EncryptionKeySchema = schema.conditional( ); const RolesSchema = schema.object({ + enabled: schema.boolean({ defaultValue: true }), // true: use ES API for access control (deprecated in 7.x). false: use Kibana API for application features (8.0) allow: schema.arrayOf(schema.string(), { defaultValue: ['reporting_user'] }), }); diff --git a/x-pack/plugins/reporting/server/core.ts b/x-pack/plugins/reporting/server/core.ts index 1e695105ac1ea..02d18fd5489d3 100644 --- a/x-pack/plugins/reporting/server/core.ts +++ b/x-pack/plugins/reporting/server/core.ts @@ -12,10 +12,12 @@ import { BasePath, IClusterClient, KibanaRequest, + PluginInitializerContext, SavedObjectsClientContract, SavedObjectsServiceStart, UiSettingsServiceStart, } from '../../../../src/core/server'; +import { PluginStart as DataPluginStart } from '../../../../src/plugins/data/server'; import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { LicensingPluginSetup } from '../../licensing/server'; import { SecurityPluginSetup } from '../../security/server'; @@ -23,12 +25,12 @@ import { DEFAULT_SPACE_ID } from '../../spaces/common/constants'; import { SpacesPluginSetup } from '../../spaces/server'; import { ReportingConfig } from './'; import { HeadlessChromiumDriverFactory } from './browsers/chromium/driver_factory'; +import { ReportingConfigType } from './config'; import { checkLicense, getExportTypesRegistry, LevelLogger } from './lib'; import { ESQueueInstance } from './lib/create_queue'; import { screenshotsObservableFactory, ScreenshotsObservableFn } from './lib/screenshots'; import { ReportingStore } from './lib/store'; -import { ReportingPluginRouter } from './types'; -import { PluginStart as DataPluginStart } from '../../../../src/plugins/data/server'; +import { ReportingPluginRouter, ReportingStart } from './types'; export interface ReportingInternalSetup { basePath: Pick; @@ -37,6 +39,7 @@ export interface ReportingInternalSetup { licensing: LicensingPluginSetup; security?: SecurityPluginSetup; spaces?: SpacesPluginSetup; + logger: LevelLogger; } export interface ReportingInternalStart { @@ -47,6 +50,7 @@ export interface ReportingInternalStart { esClient: IClusterClient; data: DataPluginStart; esqueue: ESQueueInstance; + logger: LevelLogger; } export class ReportingCore { @@ -54,10 +58,22 @@ export class ReportingCore { private pluginStartDeps?: ReportingInternalStart; private readonly pluginSetup$ = new Rx.ReplaySubject(); // observe async background setupDeps and config each are done private readonly pluginStart$ = new Rx.ReplaySubject(); // observe async background startDeps + private deprecatedAllowedRoles: string[] | false = false; // DEPRECATED. If `false`, the deprecated features have been disableed private exportTypesRegistry = getExportTypesRegistry(); private config?: ReportingConfig; - constructor(private logger: LevelLogger) {} + public getStartContract: () => ReportingStart; + + constructor(private logger: LevelLogger, context: PluginInitializerContext) { + const syncConfig = context.config.get(); + this.deprecatedAllowedRoles = syncConfig.roles.enabled ? syncConfig.roles.allow : false; + + this.getStartContract = (): ReportingStart => { + return { + usesUiCapabilities: () => syncConfig.roles.enabled === false, + }; + }; + } /* * Register setupDeps @@ -109,23 +125,38 @@ export class ReportingCore { } /** - * Registers reporting as an Elasticsearch feature for the purpose of toggling visibility based on roles. + * If xpack.reporting.roles.enabled === true, register Reporting as a feature + * that is controlled by user role names */ public registerFeature() { - const config = this.getConfig(); - const allowedRoles = ['superuser', ...(config.get('roles')?.allow ?? [])]; - this.getPluginSetupDeps().features.registerElasticsearchFeature({ - id: 'reporting', - catalogue: ['reporting'], - management: { - insightsAndAlerting: ['reporting'], - }, - privileges: allowedRoles.map((role) => ({ + const { features } = this.getPluginSetupDeps(); + const deprecatedRoles = this.getDeprecatedAllowedRoles(); + + if (deprecatedRoles !== false) { + // refer to roles.allow configuration (deprecated path) + const allowedRoles = ['superuser', ...(deprecatedRoles ?? [])]; + const privileges = allowedRoles.map((role) => ({ requiredClusterPrivileges: [], requiredRoles: [role], ui: [], - })), - }); + })); + + // self-register as an elasticsearch feature (deprecated) + features.registerElasticsearchFeature({ + id: 'reporting', + catalogue: ['reporting'], + management: { + insightsAndAlerting: ['reporting'], + }, + privileges, + }); + } else { + this.logger.debug( + `Reporting roles configuration is disabled. Please assign access to Reporting use Kibana feature controls for applications.` + ); + // trigger application to register Reporting as a subfeature + features.enableReportingUiCapabilities(); + } } /* @@ -138,6 +169,15 @@ export class ReportingCore { return this.config; } + /* + * If deprecated feature has not been disabled, + * this returns an array of allowed role names + * that have access to Reporting. + */ + public getDeprecatedAllowedRoles(): string[] | false { + return this.deprecatedAllowedRoles; + } + /* * Gives async access to the startDeps */ diff --git a/x-pack/plugins/reporting/server/export_types/csv/execute_job.test.ts b/x-pack/plugins/reporting/server/export_types/csv/execute_job.test.ts index 18cd6e826b5e7..90d34acf28ea9 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/execute_job.test.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/execute_job.test.ts @@ -22,7 +22,7 @@ import { CancellationToken } from '../../../common'; import { CSV_BOM_CHARS } from '../../../common/constants'; import { LevelLogger } from '../../lib'; import { setFieldFormats } from '../../services'; -import { createMockReportingCore } from '../../test_helpers'; +import { createMockConfigSchema, createMockReportingCore } from '../../test_helpers'; import { runTaskFnFactory } from './execute_job'; import { TaskPayloadDeprecatedCSV } from './types'; @@ -75,7 +75,7 @@ describe('CSV Execute Job', function () { configGetStub.withArgs('csv', 'scroll').returns({}); mockReportingConfig = { get: configGetStub, kbnConfig: { get: configGetStub } }; - mockReportingCore = await createMockReportingCore(mockReportingConfig); + mockReportingCore = await createMockReportingCore(createMockConfigSchema()); mockReportingCore.getUiSettingsServiceFactory = () => Promise.resolve((mockUiSettingsClient as unknown) as IUiSettingsClient); mockReportingCore.setConfig(mockReportingConfig); diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/execute_job.test.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/execute_job.test.ts index 1c2e15ebc5d9b..c9d57370ab766 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_searchsource/execute_job.test.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/execute_job.test.ts @@ -19,7 +19,6 @@ import nodeCrypto from '@elastic/node-crypto'; import { ReportingCore } from '../../'; import { CancellationToken } from '../../../common'; import { - createMockConfig, createMockConfigSchema, createMockLevelLogger, createMockReportingCore, @@ -34,7 +33,9 @@ let reportingCore: ReportingCore; beforeAll(async () => { const crypto = nodeCrypto({ encryptionKey }); - const config = createMockConfig( + + encryptedHeaders = await crypto.encrypt(headers); + reportingCore = await createMockReportingCore( createMockConfigSchema({ encryptionKey, csv: { @@ -45,10 +46,6 @@ beforeAll(async () => { }, }) ); - - encryptedHeaders = await crypto.encrypt(headers); - - reportingCore = await createMockReportingCore(config); }); test('gets the csv content from job parameters', async () => { diff --git a/x-pack/plugins/reporting/server/export_types/png/execute_job/index.test.ts b/x-pack/plugins/reporting/server/export_types/png/execute_job/index.test.ts index 34fe5360522b1..ee264f7c57ff6 100644 --- a/x-pack/plugins/reporting/server/export_types/png/execute_job/index.test.ts +++ b/x-pack/plugins/reporting/server/export_types/png/execute_job/index.test.ts @@ -9,7 +9,11 @@ import * as Rx from 'rxjs'; import { ReportingCore } from '../../../'; import { CancellationToken } from '../../../../common'; import { cryptoFactory, LevelLogger } from '../../../lib'; -import { createMockReportingCore } from '../../../test_helpers'; +import { + createMockConfig, + createMockConfigSchema, + createMockReportingCore, +} from '../../../test_helpers'; import { generatePngObservableFactory } from '../lib/generate_png'; import { TaskPayloadPNG } from '../types'; import { runTaskFnFactory } from './'; @@ -40,27 +44,17 @@ const encryptHeaders = async (headers: Record) => { const getBasePayload = (baseObj: any) => baseObj as TaskPayloadPNG; beforeEach(async () => { - const kbnConfig = { - 'server.basePath': '/sbp', - }; - const reportingConfig = { + const mockReportingConfig = createMockConfigSchema({ index: '.reporting-2018.10.10', encryptionKey: mockEncryptionKey, - 'kibanaServer.hostname': 'localhost', - 'kibanaServer.port': 5601, - 'kibanaServer.protocol': 'http', - 'queue.indexInterval': 'daily', - 'queue.timeout': Infinity, - }; - const mockReportingConfig = { - get: (...keys: string[]) => (reportingConfig as any)[keys.join('.')], - kbnConfig: { get: (...keys: string[]) => (kbnConfig as any)[keys.join('.')] }, - }; + queue: { + indexInterval: 'daily', + timeout: Infinity, + }, + }); mockReporting = await createMockReportingCore(mockReportingConfig); - - // @ts-ignore over-riding config method - mockReporting.config = mockReportingConfig; + mockReporting.setConfig(createMockConfig(mockReportingConfig)); (generatePngObservableFactory as jest.Mock).mockReturnValue(jest.fn()); }); @@ -98,14 +92,14 @@ test(`passes browserTimezone to generatePng`, async () => { ], "warning": [Function], }, - "http://localhost:5601/sbp/app/kibana#/something", + "localhost:80undefined/app/kibana#/something", "UTC", Object { "conditions": Object { - "basePath": "/sbp", + "basePath": undefined, "hostname": "localhost", - "port": 5601, - "protocol": "http", + "port": 80, + "protocol": undefined, }, "headers": Object {}, }, diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.test.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.test.ts index d3b2a9be72522..6e0314c2dff56 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.test.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.test.ts @@ -11,11 +11,7 @@ import * as Rx from 'rxjs'; import { ReportingCore } from '../../../'; import { CancellationToken } from '../../../../common'; import { cryptoFactory, LevelLogger } from '../../../lib'; -import { - createMockConfig, - createMockConfigSchema, - createMockReportingCore, -} from '../../../test_helpers'; +import { createMockConfigSchema, createMockReportingCore } from '../../../test_helpers'; import { generatePdfObservableFactory } from '../lib/generate_pdf'; import { TaskPayloadPDF } from '../types'; import { runTaskFnFactory } from './'; @@ -53,12 +49,7 @@ beforeEach(async () => { 'kibanaServer.protocol': 'http', }; const mockSchema = createMockConfigSchema(reportingConfig); - const mockReportingConfig = createMockConfig(mockSchema); - - mockReporting = await createMockReportingCore(mockReportingConfig); - - // @ts-ignore over-riding config - mockReporting.config = mockReportingConfig; + mockReporting = await createMockReportingCore(mockSchema); (generatePdfObservableFactory as jest.Mock).mockReturnValue(jest.fn()); }); diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/get_custom_logo.test.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/get_custom_logo.test.ts index ed58fef2f5dc8..ebdceda0820b9 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/get_custom_logo.test.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/get_custom_logo.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ReportingConfig, ReportingCore } from '../../../'; +import { ReportingCore } from '../../../'; import { createMockConfig, createMockConfigSchema, @@ -15,14 +15,12 @@ import { import { getConditionalHeaders } from '../../common'; import { getCustomLogo } from './get_custom_logo'; -let mockConfig: ReportingConfig; let mockReportingPlugin: ReportingCore; const logger = createMockLevelLogger(); beforeEach(async () => { - mockConfig = createMockConfig(createMockConfigSchema()); - mockReportingPlugin = await createMockReportingCore(mockConfig); + mockReportingPlugin = await createMockReportingCore(createMockConfigSchema()); }); test(`gets logo from uiSettings`, async () => { @@ -42,7 +40,10 @@ test(`gets logo from uiSettings`, async () => { get: mockGet, }); - const conditionalHeaders = getConditionalHeaders(mockConfig, permittedHeaders); + const conditionalHeaders = getConditionalHeaders( + createMockConfig(createMockConfigSchema()), + permittedHeaders + ); const { logo } = await getCustomLogo( mockReportingPlugin, diff --git a/x-pack/plugins/reporting/server/index.ts b/x-pack/plugins/reporting/server/index.ts index 0233e4dfa4ebd..999311b9ae17b 100644 --- a/x-pack/plugins/reporting/server/index.ts +++ b/x-pack/plugins/reporting/server/index.ts @@ -6,17 +6,20 @@ */ import { PluginInitializerContext } from 'kibana/server'; -import { ReportingPlugin } from './plugin'; import { ReportingConfigType } from './config'; +import { ReportingPlugin } from './plugin'; export const plugin = (initContext: PluginInitializerContext) => new ReportingPlugin(initContext); -export { ReportingPlugin as Plugin }; export { config } from './config'; -export { ReportingSetupDeps as PluginSetup } from './types'; -export { ReportingStartDeps as PluginStart } from './types'; - +export { ReportingConfig } from './config/config'; // internal imports export { ReportingCore } from './core'; -export { ReportingConfig } from './config/config'; +export { + ReportingSetup, + ReportingSetupDeps as PluginSetup, + ReportingStartDeps as PluginStart, +} from './types'; + +export { ReportingPlugin as Plugin }; diff --git a/x-pack/plugins/reporting/server/lib/create_worker.test.ts b/x-pack/plugins/reporting/server/lib/create_worker.test.ts index 23392ebbb4a8f..448e797acd59d 100644 --- a/x-pack/plugins/reporting/server/lib/create_worker.test.ts +++ b/x-pack/plugins/reporting/server/lib/create_worker.test.ts @@ -6,12 +6,11 @@ */ import type { DeeplyMockedKeys } from '@kbn/utility-types/jest'; -import * as sinon from 'sinon'; import { ElasticsearchClient } from 'kibana/server'; +import * as sinon from 'sinon'; import { elasticsearchServiceMock } from 'src/core/server/mocks'; -import { ReportingConfig, ReportingCore } from '../../server'; +import { ReportingCore } from '../../server'; import { - createMockConfig, createMockConfigSchema, createMockLevelLogger, createMockReportingCore, @@ -38,14 +37,11 @@ const getMockExportTypesRegistry = ( describe('Create Worker', () => { let mockReporting: ReportingCore; - let mockConfig: ReportingConfig; let queue: Esqueue; let client: DeeplyMockedKeys; beforeEach(async () => { - const mockSchema = createMockConfigSchema(reportingConfig); - mockConfig = createMockConfig(mockSchema); - mockReporting = await createMockReportingCore(mockConfig); + mockReporting = await createMockReportingCore(createMockConfigSchema(reportingConfig)); mockReporting.getExportTypesRegistry = () => getMockExportTypesRegistry(); ({ asInternalUser: client } = elasticsearchServiceMock.createClusterClient()); diff --git a/x-pack/plugins/reporting/server/lib/enqueue_job.test.ts b/x-pack/plugins/reporting/server/lib/enqueue_job.test.ts new file mode 100644 index 0000000000000..d9d1815835baa --- /dev/null +++ b/x-pack/plugins/reporting/server/lib/enqueue_job.test.ts @@ -0,0 +1,91 @@ +/* + * 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 { KibanaRequest } from 'src/core/server'; +import { ReportingCore } from '../'; +import { TaskManagerStartContract } from '../../../task_manager/server'; +import { ReportingInternalStart } from '../core'; +import { + createMockConfigSchema, + createMockLevelLogger, + createMockReportingCore, +} from '../test_helpers'; +import { BasePayload, ReportingRequestHandlerContext } from '../types'; +import { ExportTypesRegistry, ReportingStore } from './'; +import { enqueueJobFactory } from './enqueue_job'; +import { Report } from './store'; +import { TaskRunResult } from './tasks'; + +describe('Enqueue Job', () => { + const logger = createMockLevelLogger(); + let mockReporting: ReportingCore; + let mockExportTypesRegistry: ExportTypesRegistry; + + beforeAll(async () => { + mockExportTypesRegistry = new ExportTypesRegistry(); + mockExportTypesRegistry.register({ + id: 'printablePdf', + name: 'Printable PDFble', + jobType: 'printable_pdf', + jobContentEncoding: 'base64', + jobContentExtension: 'pdf', + validLicenses: ['turquoise'], + createJobFnFactory: () => async () => + (({ createJobTest: { test1: 'yes' } } as unknown) as BasePayload), + runTaskFnFactory: () => async () => + (({ runParamsTest: { test2: 'yes' } } as unknown) as TaskRunResult), + }); + mockReporting = await createMockReportingCore(createMockConfigSchema()); + mockReporting.getExportTypesRegistry = () => mockExportTypesRegistry; + mockReporting.getStore = () => + Promise.resolve(({ + addReport: jest + .fn() + .mockImplementation( + (report) => new Report({ ...report, _index: '.reporting-foo-index-234' }) + ), + } as unknown) as ReportingStore); + + const scheduleMock = jest.fn().mockImplementation(() => ({ + id: '123-great-id', + })); + + await mockReporting.pluginStart(({ + taskManager: ({ + ensureScheduled: jest.fn(), + schedule: scheduleMock, + } as unknown) as TaskManagerStartContract, + } as unknown) as ReportingInternalStart); + }); + + it('returns a Report object', async () => { + const enqueueJob = enqueueJobFactory(mockReporting, logger); + const report = await enqueueJob( + 'printablePdf', + { + objectType: 'visualization', + title: 'cool-viz', + }, + false, + ({} as unknown) as ReportingRequestHandlerContext, + ({} as unknown) as KibanaRequest + ); + + expect(report).toMatchObject({ + _id: expect.any(String), + _index: '.reporting-foo-index-234', + attempts: 0, + created_by: false, + created_at: expect.any(String), + jobtype: 'printable_pdf', + meta: { objectType: 'visualization' }, + output: null, + payload: { createJobTest: { test1: 'yes' } }, + status: 'pending', + }); + }); +}); diff --git a/x-pack/plugins/reporting/server/lib/store/store.test.ts b/x-pack/plugins/reporting/server/lib/store/store.test.ts index 3be54aa8ab8b5..a2c6343eaafb9 100644 --- a/x-pack/plugins/reporting/server/lib/store/store.test.ts +++ b/x-pack/plugins/reporting/server/lib/store/store.test.ts @@ -7,9 +7,8 @@ import type { DeeplyMockedKeys } from '@kbn/utility-types/jest'; import { ElasticsearchClient } from 'src/core/server'; -import { ReportingConfig, ReportingCore } from '../..'; +import { ReportingCore } from '../..'; import { - createMockConfig, createMockConfigSchema, createMockLevelLogger, createMockReportingCore, @@ -19,7 +18,6 @@ import { ReportingStore } from './store'; describe('ReportingStore', () => { const mockLogger = createMockLevelLogger(); - let mockConfig: ReportingConfig; let mockCore: ReportingCore; let mockEsClient: DeeplyMockedKeys; @@ -28,9 +26,7 @@ describe('ReportingStore', () => { index: '.reporting-test', queue: { indexInterval: 'week' }, }; - const mockSchema = createMockConfigSchema(reportingConfig); - mockConfig = createMockConfig(mockSchema); - mockCore = await createMockReportingCore(mockConfig); + mockCore = await createMockReportingCore(createMockConfigSchema(reportingConfig)); mockEsClient = (await mockCore.getEsClient()).asInternalUser as typeof mockEsClient; mockEsClient.indices.create.mockResolvedValue({} as any); @@ -71,9 +67,7 @@ describe('ReportingStore', () => { index: '.reporting-test', queue: { indexInterval: 'centurially' }, }; - const mockSchema = createMockConfigSchema(reportingConfig); - mockConfig = createMockConfig(mockSchema); - mockCore = await createMockReportingCore(mockConfig); + mockCore = await createMockReportingCore(createMockConfigSchema(reportingConfig)); const store = new ReportingStore(mockCore, mockLogger); const mockReport = new Report({ diff --git a/x-pack/plugins/reporting/server/plugin.test.ts b/x-pack/plugins/reporting/server/plugin.test.ts index c21bc7376b0b3..c6868782f8cdd 100644 --- a/x-pack/plugins/reporting/server/plugin.test.ts +++ b/x-pack/plugins/reporting/server/plugin.test.ts @@ -65,23 +65,6 @@ describe('Reporting Plugin', () => { expect(plugin.setup(coreSetup, pluginSetup)).not.toHaveProperty('then'); }); - it('logs setup issues', async () => { - initContext.config = null; - const plugin = new ReportingPlugin(initContext); - // @ts-ignore overloading error logger - plugin.logger.error = jest.fn(); - plugin.setup(coreSetup, pluginSetup); - - await sleep(5); - - // @ts-ignore overloading error logger - expect(plugin.logger.error.mock.calls[0][0]).toMatch( - /Error in Reporting setup, reporting may not function properly/ - ); - // @ts-ignore overloading error logger - expect(plugin.logger.error).toHaveBeenCalledTimes(2); - }); - it('has a sync startup process', async () => { const plugin = new ReportingPlugin(initContext); plugin.setup(coreSetup, pluginSetup); diff --git a/x-pack/plugins/reporting/server/plugin.ts b/x-pack/plugins/reporting/server/plugin.ts index 8bc65421731b5..7794bfd307e7c 100644 --- a/x-pack/plugins/reporting/server/plugin.ts +++ b/x-pack/plugins/reporting/server/plugin.ts @@ -24,23 +24,23 @@ import { registerReportingUsageCollector } from './usage'; export class ReportingPlugin implements Plugin { - private readonly initializerContext: PluginInitializerContext; private logger: LevelLogger; - private reportingCore: ReportingCore; + private reportingCore?: ReportingCore; - constructor(context: PluginInitializerContext) { - this.logger = new LevelLogger(context.logger.get()); - this.reportingCore = new ReportingCore(this.logger); - this.initializerContext = context; + constructor(private initContext: PluginInitializerContext) { + this.logger = new LevelLogger(initContext.logger.get()); } public setup(core: CoreSetup, plugins: ReportingSetupDeps) { + const reportingCore = new ReportingCore(this.logger, this.initContext); + // prevent throwing errors in route handlers about async deps not being initialized // @ts-expect-error null is not assignable to object. use a boolean property to ensure reporting API is enabled. core.http.registerRouteHandlerContext(PLUGIN_ID, () => { - if (this.reportingCore.pluginIsStarted()) { - return {}; // ReportingStart contract + if (reportingCore.pluginIsStarted()) { + return reportingCore.getStartContract(); } else { + this.logger.error(`Reporting features are not yet ready`); return null; } }); @@ -49,7 +49,6 @@ export class ReportingPlugin const { http } = core; const { features, licensing, security, spaces } = plugins; - const { initializerContext: initContext, reportingCore } = this; const router = http.createRouter(); const basePath = http.basePath; @@ -61,6 +60,7 @@ export class ReportingPlugin router, security, spaces, + logger: this.logger, }); registerReportingUsageCollector(reportingCore, plugins); @@ -68,7 +68,7 @@ export class ReportingPlugin // async background setup (async () => { - const config = await buildConfig(initContext, core, this.logger); + const config = await buildConfig(this.initContext, core, this.logger); reportingCore.setConfig(config); // Feature registration relies on config, so it cannot be setup before here. reportingCore.registerFeature(); @@ -78,26 +78,26 @@ export class ReportingPlugin this.logger.error(e); }); - return {}; + this.reportingCore = reportingCore; + return reportingCore.getStartContract(); } public start(core: CoreStart, plugins: ReportingStartDeps) { // use data plugin for csv formats setFieldFormats(plugins.data.fieldFormats); - - const { logger, reportingCore } = this; + const reportingCore = this.reportingCore!; // async background start (async () => { - await this.reportingCore.pluginSetsUp(); + await reportingCore.pluginSetsUp(); const config = reportingCore.getConfig(); - const browserDriverFactory = await initializeBrowserDriverFactory(config, logger); - const store = new ReportingStore(reportingCore, logger); + const browserDriverFactory = await initializeBrowserDriverFactory(config, this.logger); + const store = new ReportingStore(reportingCore, this.logger); const esqueue = await createQueueFactory( reportingCore, store, - logger, + this.logger, core.elasticsearch.client.asInternalUser ); // starts polling for pending jobs @@ -109,6 +109,7 @@ export class ReportingPlugin esClient: core.elasticsearch.client, data: plugins.data, esqueue, + logger: this.logger, }); this.logger.debug('Start complete'); @@ -117,6 +118,6 @@ export class ReportingPlugin this.logger.error(e); }); - return {}; + return reportingCore.getStartContract(); } } diff --git a/x-pack/plugins/reporting/server/routes/csv_searchsource_immediate.ts b/x-pack/plugins/reporting/server/routes/csv_searchsource_immediate.ts index 5d2b77c082ca5..2da509f024c25 100644 --- a/x-pack/plugins/reporting/server/routes/csv_searchsource_immediate.ts +++ b/x-pack/plugins/reporting/server/routes/csv_searchsource_immediate.ts @@ -38,6 +38,13 @@ export function registerGenerateCsvFromSavedObjectImmediate( const userHandler = authorizedUserPreRoutingFactory(reporting); const { router } = setupDeps; + // TODO: find a way to abstract this using ExportTypeRegistry: it needs a new + // public method to return this array + // const registry = reporting.getExportTypesRegistry(); + // const kibanaAccessControlTags = registry.getAllAccessControlTags(); + const useKibanaAccessControl = reporting.getDeprecatedAllowedRoles() === false; // true if deprecated config is turned off + const kibanaAccessControlTags = useKibanaAccessControl ? ['access:downloadCsv'] : []; + // This API calls run the SearchSourceImmediate export type's runTaskFn directly router.post( { @@ -50,6 +57,9 @@ export function registerGenerateCsvFromSavedObjectImmediate( title: schema.string(), }), }, + options: { + tags: kibanaAccessControlTags, + }, }, userHandler(async (user, context, req: CsvFromSavedObjectRequest, res) => { const logger = parentLogger.clone(['csv_searchsource_immediate']); diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/browser.test.ts b/x-pack/plugins/reporting/server/routes/diagnostic/browser.test.ts index 25713c2acc9e4..9c541427049ec 100644 --- a/x-pack/plugins/reporting/server/routes/diagnostic/browser.test.ts +++ b/x-pack/plugins/reporting/server/routes/diagnostic/browser.test.ts @@ -12,12 +12,13 @@ import { setupServer } from 'src/core/server/test_utils'; import supertest from 'supertest'; import { ReportingCore } from '../..'; import { + createMockConfigSchema, createMockLevelLogger, createMockPluginSetup, createMockReportingCore, } from '../../test_helpers'; -import { registerDiagnoseBrowser } from './browser'; import type { ReportingRequestHandlerContext } from '../../types'; +import { registerDiagnoseBrowser } from './browser'; jest.mock('child_process'); jest.mock('readline'); @@ -38,25 +39,17 @@ describe('POST /diagnose/browser', () => { const mockedSpawn: any = spawn; const mockedCreateInterface: any = createInterface; - const config = { - get: jest.fn().mockImplementation((...keys) => { - const key = keys.join('.'); - switch (key) { - case 'queue.timeout': - return 120000; - case 'capture.browser.chromium.proxy': - return { enabled: false }; - } - }), - kbnConfig: { get: jest.fn() }, - }; + const config = createMockConfigSchema({ + queue: { timeout: 120000 }, + capture: { browser: { chromium: { proxy: { enabled: false } } } }, + }); beforeEach(async () => { ({ server, httpSetup } = await setupServer(reportingSymbol)); httpSetup.registerRouteHandlerContext( reportingSymbol, 'reporting', - () => ({}) + () => ({ usesUiCapabilities: () => false }) ); const mockSetupDeps = createMockPluginSetup({ diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/config.test.ts b/x-pack/plugins/reporting/server/routes/diagnostic/config.test.ts index 952a33ff64190..9e6a7769f6351 100644 --- a/x-pack/plugins/reporting/server/routes/diagnostic/config.test.ts +++ b/x-pack/plugins/reporting/server/routes/diagnostic/config.test.ts @@ -11,13 +11,16 @@ import { ElasticsearchClient } from 'kibana/server'; import { setupServer } from 'src/core/server/test_utils'; import supertest from 'supertest'; import { ReportingCore } from '../..'; +import { ReportingConfigType } from '../../config'; import { - createMockReportingCore, + createMockConfig, + createMockConfigSchema, createMockLevelLogger, createMockPluginSetup, + createMockReportingCore, } from '../../test_helpers'; -import { registerDiagnoseConfig } from './config'; import type { ReportingRequestHandlerContext } from '../../types'; +import { registerDiagnoseConfig } from './config'; type SetupServerReturn = UnwrapPromise>; @@ -27,7 +30,7 @@ describe('POST /diagnose/config', () => { let httpSetup: SetupServerReturn['httpSetup']; let core: ReportingCore; let mockSetupDeps: any; - let config: any; + let config: ReportingConfigType; let mockEsClient: DeeplyMockedKeys; const mockLogger = createMockLevelLogger(); @@ -37,26 +40,14 @@ describe('POST /diagnose/config', () => { httpSetup.registerRouteHandlerContext( reportingSymbol, 'reporting', - () => ({}) + () => ({ usesUiCapabilities: () => false }) ); mockSetupDeps = createMockPluginSetup({ router: httpSetup.createRouter(''), } as unknown) as any; - config = { - get: jest.fn().mockImplementation((...keys) => { - const key = keys.join('.'); - switch (key) { - case 'queue.timeout': - return 120000; - case 'csv.maxSizeBytes': - return 1024; - } - }), - kbnConfig: { get: jest.fn() }, - }; - + config = createMockConfigSchema({ queue: { timeout: 120000 }, csv: { maxSizeBytes: 1024 } }); core = await createMockReportingCore(config, mockSetupDeps); mockEsClient = (await core.getEsClient()).asInternalUser as typeof mockEsClient; }); @@ -94,7 +85,11 @@ describe('POST /diagnose/config', () => { }); it('returns a 200 with help text when not configured properly', async () => { - config.get.mockImplementation(() => 10485760); + core.setConfig( + createMockConfig( + createMockConfigSchema({ queue: { timeout: 120000 }, csv: { maxSizeBytes: 10485760 } }) + ) + ); mockEsClient.cluster.getSettings.mockResolvedValueOnce({ body: { defaults: { diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.test.ts b/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.test.ts index 822dc6f5199a3..f151870004df7 100644 --- a/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.test.ts +++ b/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.test.ts @@ -13,6 +13,7 @@ import { createMockReportingCore, createMockLevelLogger, createMockPluginSetup, + createMockConfigSchema, } from '../../test_helpers'; import { registerDiagnoseScreenshot } from './screenshot'; import type { ReportingRequestHandlerContext } from '../../types'; @@ -38,14 +39,7 @@ describe('POST /diagnose/screenshot', () => { (generatePngObservableFactory as any).mockResolvedValue(generateMock); }; - const config = { - get: jest.fn().mockImplementation((...keys) => { - if (keys.join('.') === 'queue.timeout') { - return 120000; - } - }), - kbnConfig: { get: jest.fn() }, - }; + const config = createMockConfigSchema({ queue: { timeout: 120000 } }); const mockLogger = createMockLevelLogger(); beforeEach(async () => { @@ -53,7 +47,7 @@ describe('POST /diagnose/screenshot', () => { httpSetup.registerRouteHandlerContext( reportingSymbol, 'reporting', - () => ({}) + () => ({ usesUiCapabilities: () => false }) ); const mockSetupDeps = createMockPluginSetup({ diff --git a/x-pack/plugins/reporting/server/routes/generate_from_jobparams.ts b/x-pack/plugins/reporting/server/routes/generate_from_jobparams.ts index 681d93f1f6dff..55d12e5c6d442 100644 --- a/x-pack/plugins/reporting/server/routes/generate_from_jobparams.ts +++ b/x-pack/plugins/reporting/server/routes/generate_from_jobparams.ts @@ -24,26 +24,22 @@ export function registerGenerateFromJobParams( const userHandler = authorizedUserPreRoutingFactory(reporting); const { router } = setupDeps; + // TODO: find a way to abstract this using ExportTypeRegistry: it needs a new + // public method to return this array + // const registry = reporting.getExportTypesRegistry(); + // const kibanaAccessControlTags = registry.getAllAccessControlTags(); + const useKibanaAccessControl = reporting.getDeprecatedAllowedRoles() === false; // true if Reporting's deprecated access control feature is disabled + const kibanaAccessControlTags = useKibanaAccessControl ? ['access:generateReport'] : []; + router.post( { path: `${BASE_GENERATE}/{exportType}`, validate: { - 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: '', - }), - }) - ), + 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: '' }) })), }, + options: { tags: kibanaAccessControlTags }, }, userHandler(async (user, context, req, res) => { let jobParamsRison: null | string = null; diff --git a/x-pack/plugins/reporting/server/routes/generation.test.ts b/x-pack/plugins/reporting/server/routes/generation.test.ts index 0ce977e0a5431..c6889f3612b59 100644 --- a/x-pack/plugins/reporting/server/routes/generation.test.ts +++ b/x-pack/plugins/reporting/server/routes/generation.test.ts @@ -14,7 +14,10 @@ import supertest from 'supertest'; import { ReportingCore } from '..'; import { ExportTypesRegistry } from '../lib/export_types_registry'; import { createMockLevelLogger, createMockReportingCore } from '../test_helpers'; -import { createMockPluginSetup } from '../test_helpers/create_mock_reportingplugin'; +import { + createMockConfigSchema, + createMockPluginSetup, +} from '../test_helpers/create_mock_reportingplugin'; import { registerJobGenerationRoutes } from './generation'; import type { ReportingRequestHandlerContext } from '../types'; @@ -28,24 +31,15 @@ describe('POST /api/reporting/generate', () => { let core: ReportingCore; let mockEsClient: DeeplyMockedKeys; - const config = { - get: jest.fn().mockImplementation((...args) => { - const key = args.join('.'); - switch (key) { - case 'queue.indexInterval': - return 'year'; - case 'queue.timeout': - return 10000; - case 'index': - return '.reporting'; - case 'queue.pollEnabled': - return true; - default: - return; - } - }), - kbnConfig: { get: jest.fn() }, - }; + const config = createMockConfigSchema({ + queue: { + indexInterval: 'year', + timeout: 10000, + pollEnabled: true, + }, + index: '.reporting', + }); + const mockLogger = createMockLevelLogger(); beforeEach(async () => { @@ -53,7 +47,7 @@ describe('POST /api/reporting/generate', () => { httpSetup.registerRouteHandlerContext( reportingSymbol, 'reporting', - () => ({}) + () => ({ usesUiCapabilities: jest.fn() }) ); const mockSetupDeps = createMockPluginSetup({ diff --git a/x-pack/plugins/reporting/server/routes/jobs.test.ts b/x-pack/plugins/reporting/server/routes/jobs.test.ts index 885fc701935fe..3f913dfd1f32f 100644 --- a/x-pack/plugins/reporting/server/routes/jobs.test.ts +++ b/x-pack/plugins/reporting/server/routes/jobs.test.ts @@ -15,7 +15,6 @@ import { ReportingCore } from '..'; import { ReportingInternalSetup } from '../core'; import { ExportTypesRegistry } from '../lib/export_types_registry'; import { - createMockConfig, createMockConfigSchema, createMockPluginSetup, createMockReportingCore, @@ -31,9 +30,9 @@ describe('GET /api/reporting/jobs/download', () => { let httpSetup: SetupServerReturn['httpSetup']; let exportTypesRegistry: ExportTypesRegistry; let core: ReportingCore; + let mockSetupDeps: ReportingInternalSetup; let mockEsClient: DeeplyMockedKeys; - const config = createMockConfig(createMockConfigSchema()); const getHits = (...sources: any) => { return { hits: { @@ -47,9 +46,9 @@ describe('GET /api/reporting/jobs/download', () => { httpSetup.registerRouteHandlerContext( reportingSymbol, 'reporting', - () => ({}) + () => ({ usesUiCapabilities: jest.fn() }) ); - const mockSetupDeps = createMockPluginSetup({ + mockSetupDeps = createMockPluginSetup({ security: { license: { isEnabled: () => true, @@ -72,7 +71,10 @@ describe('GET /api/reporting/jobs/download', () => { }, }); - core = await createMockReportingCore(config, mockSetupDeps); + core = await createMockReportingCore( + createMockConfigSchema({ roles: { enabled: false } }), + mockSetupDeps + ); // @ts-ignore exportTypesRegistry = new ExportTypesRegistry(); exportTypesRegistry.register({ @@ -139,36 +141,6 @@ describe('GET /api/reporting/jobs/download', () => { ); }); - it('fails on users without the appropriate role', async () => { - // @ts-ignore - core.pluginSetupDeps = ({ - // @ts-ignore - ...core.pluginSetupDeps, - security: { - license: { - isEnabled: () => true, - }, - authc: { - getCurrentUser: () => ({ - id: '123', - roles: ['peasant'], - username: 'Tom Riddle', - }), - }, - }, - } as unknown) as ReportingInternalSetup; - registerJobInfoRoutes(core); - - await server.start(); - - await supertest(httpSetup.server.listener) - .get('/api/reporting/jobs/download/dope') - .expect(403) - .then(({ body }) => - expect(body.message).toMatchInlineSnapshot(`"Sorry, you don't have access to Reporting"`) - ); - }); - it('returns 404 if job not found', async () => { mockEsClient.search.mockResolvedValueOnce({ body: getHits() } as any); registerJobInfoRoutes(core); @@ -329,4 +301,38 @@ describe('GET /api/reporting/jobs/download', () => { }); }); }); + + describe('Deprecated: role-based access control', () => { + it('fails on users without the appropriate role', async () => { + const deprecatedConfig = createMockConfigSchema({ roles: { enabled: true } }); + core = await createMockReportingCore(deprecatedConfig, mockSetupDeps); + // @ts-ignore + core.pluginSetupDeps = ({ + // @ts-ignore + ...core.pluginSetupDeps, + security: { + license: { + isEnabled: () => true, + }, + authc: { + getCurrentUser: () => ({ + id: '123', + roles: ['peasant'], + username: 'Tom Riddle', + }), + }, + }, + } as unknown) as ReportingInternalSetup; + registerJobInfoRoutes(core); + + await server.start(); + + await supertest(httpSetup.server.listener) + .get('/api/reporting/jobs/download/dope') + .expect(403) + .then(({ body }) => + expect(body.message).toMatchInlineSnapshot(`"Sorry, you don't have access to Reporting"`) + ); + }); + }); }); diff --git a/x-pack/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.ts b/x-pack/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.ts index 0f1bfa38ee6c8..16ef9e6d5bc10 100644 --- a/x-pack/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.ts +++ b/x-pack/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.ts @@ -5,22 +5,16 @@ * 2.0. */ -import { KibanaRequest, KibanaResponseFactory } from 'kibana/server'; +import { KibanaRequest, KibanaResponseFactory } from 'src/core/server'; import { coreMock, httpServerMock } from 'src/core/server/mocks'; import { ReportingCore } from '../../'; import { ReportingInternalSetup } from '../../core'; -import { - createMockConfig, - createMockConfigSchema, - createMockReportingCore, -} from '../../test_helpers'; -import { authorizedUserPreRoutingFactory } from './authorized_user_pre_routing'; +import { createMockConfigSchema, createMockReportingCore } from '../../test_helpers'; import type { ReportingRequestHandlerContext } from '../../types'; +import { authorizedUserPreRoutingFactory } from './authorized_user_pre_routing'; let mockCore: ReportingCore; -const mockConfig: any = { 'server.basePath': '/sbp', 'roles.allow': ['reporting_user'] }; -const mockReportingConfigSchema = createMockConfigSchema(mockConfig); -const mockReportingConfig = createMockConfig(mockReportingConfigSchema); +const mockReportingConfig = createMockConfigSchema({ roles: { enabled: false } }); const getMockContext = () => (({ @@ -111,50 +105,64 @@ describe('authorized_user_pre_routing', function () { }); }); - it(`should return with 403 when security is enabled but user doesn't have the allowed role`, async function () { - mockCore.getPluginSetupDeps = () => - (({ - // @ts-ignore - ...mockCore.pluginSetupDeps, - security: { - license: { isEnabled: () => true }, - authc: { getCurrentUser: () => ({ username: 'friendlyuser', roles: ['cowboy'] }) }, + describe('Deprecated: security roles for access control', () => { + beforeEach(async () => { + const mockReportingConfigDeprecated = createMockConfigSchema({ + roles: { + allow: ['reporting_user'], + enabled: true, }, - } as unknown) as ReportingInternalSetup); - const authorizedUserPreRouting = authorizedUserPreRoutingFactory(mockCore); - const mockResponseFactory = getMockResponseFactory(); + }); + mockCore = await createMockReportingCore(mockReportingConfigDeprecated); + }); - const mockHandler = () => { - throw new Error('Handler callback should not be called'); - }; - expect( - authorizedUserPreRouting(mockHandler)(getMockContext(), getMockRequest(), mockResponseFactory) - ).toMatchObject({ body: `Sorry, you don't have access to Reporting` }); - }); + it(`should return with 403 when security is enabled but user doesn't have the allowed role`, async function () { + mockCore.getPluginSetupDeps = () => + (({ + // @ts-ignore + ...mockCore.pluginSetupDeps, + security: { + license: { isEnabled: () => true }, + authc: { getCurrentUser: () => ({ username: 'friendlyuser', roles: ['cowboy'] }) }, + }, + } as unknown) as ReportingInternalSetup); + const authorizedUserPreRouting = authorizedUserPreRoutingFactory(mockCore); + const mockResponseFactory = getMockResponseFactory(); + + const mockHandler = () => { + throw new Error('Handler callback should not be called'); + }; + expect( + authorizedUserPreRouting(mockHandler)( + getMockContext(), + getMockRequest(), + mockResponseFactory + ) + ).toMatchObject({ body: `Sorry, you don't have access to Reporting` }); + }); - it('should return from handler when security is enabled and user has explicitly allowed role', function (done) { - mockCore.getPluginSetupDeps = () => - (({ - // @ts-ignore - ...mockCore.pluginSetupDeps, - security: { - license: { isEnabled: () => true }, - authc: { - getCurrentUser: () => ({ username: 'friendlyuser', roles: ['reporting_user'] }), + it('should return from handler when security is enabled and user has explicitly allowed role', function (done) { + mockCore.getPluginSetupDeps = () => + (({ + // @ts-ignore + ...mockCore.pluginSetupDeps, + security: { + license: { isEnabled: () => true }, + authc: { + getCurrentUser: () => ({ username: 'friendlyuser', roles: ['reporting_user'] }), + }, }, - }, - } as unknown) as ReportingInternalSetup); - // @ts-ignore overloading config getter - mockCore.config = mockReportingConfig; - const authorizedUserPreRouting = authorizedUserPreRoutingFactory(mockCore); - const mockResponseFactory = getMockResponseFactory(); + } as unknown) as ReportingInternalSetup); + const authorizedUserPreRouting = authorizedUserPreRoutingFactory(mockCore); + const mockResponseFactory = getMockResponseFactory(); + + authorizedUserPreRouting((user) => { + expect(user).toMatchObject({ roles: ['reporting_user'], username: 'friendlyuser' }); + done(); + return Promise.resolve({ status: 200, options: {} }); + })(getMockContext(), getMockRequest(), mockResponseFactory); + }); - authorizedUserPreRouting((user) => { - expect(user).toMatchObject({ roles: ['reporting_user'], username: 'friendlyuser' }); - done(); - return Promise.resolve({ status: 200, options: {} }); - })(getMockContext(), getMockRequest(), mockResponseFactory); + it('should return from handler when security is enabled and user has superuser role', async function () {}); }); - - it('should return from handler when security is enabled and user has superuser role', async function () {}); }); diff --git a/x-pack/plugins/reporting/server/routes/lib/authorized_user_pre_routing.ts b/x-pack/plugins/reporting/server/routes/lib/authorized_user_pre_routing.ts index d2576224fc792..846d8c28a5378 100644 --- a/x-pack/plugins/reporting/server/routes/lib/authorized_user_pre_routing.ts +++ b/x-pack/plugins/reporting/server/routes/lib/authorized_user_pre_routing.ts @@ -26,35 +26,40 @@ export type RequestHandlerUser = RequestHandler< export const authorizedUserPreRoutingFactory = function authorizedUserPreRoutingFn( reporting: ReportingCore ) { - const setupDeps = reporting.getPluginSetupDeps(); - const getUser = getUserFactory(setupDeps.security); + const { logger, security } = reporting.getPluginSetupDeps(); + const getUser = getUserFactory(security); return ( handler: RequestHandlerUser ): RequestHandler => { return (context, req, res) => { - let user: ReportingRequestUser = false; - if (setupDeps.security && setupDeps.security.license.isEnabled()) { - // find the authenticated user, or null if security is not enabled - user = getUser(req); - if (!user) { - // security is enabled but the user is null - return res.unauthorized({ body: `Sorry, you aren't authenticated` }); + try { + let user: ReportingRequestUser = false; + if (security && security.license.isEnabled()) { + // find the authenticated user, or null if security is not enabled + user = getUser(req); + if (!user) { + // security is enabled but the user is null + return res.unauthorized({ body: `Sorry, you aren't authenticated` }); + } } - } - if (user) { - // check allowance with the configured set of roleas + "superuser" - const config = reporting.getConfig(); - const allowedRoles = config.get('roles', 'allow') || []; - const authorizedRoles = [superuserRole, ...allowedRoles]; + const deprecatedAllowedRoles = reporting.getDeprecatedAllowedRoles(); + if (user && deprecatedAllowedRoles !== false) { + // check allowance with the configured set of roleas + "superuser" + const allowedRoles = deprecatedAllowedRoles || []; + const authorizedRoles = [superuserRole, ...allowedRoles]; - if (!user.roles.find((role) => authorizedRoles.includes(role))) { - // user's roles do not allow - return res.forbidden({ body: `Sorry, you don't have access to Reporting` }); + if (!user.roles.find((role) => authorizedRoles.includes(role))) { + // user's roles do not allow + return res.forbidden({ body: `Sorry, you don't have access to Reporting` }); + } } - } - return handler(user, context, req, res); + return handler(user, context, req, res); + } catch (err) { + logger.error(err); + return res.custom({ statusCode: 500 }); + } }; }; }; diff --git a/x-pack/plugins/reporting/server/routes/lib/job_response_handler.ts b/x-pack/plugins/reporting/server/routes/lib/job_response_handler.ts index cbdb39f7a935e..8ffefa9c8a98c 100644 --- a/x-pack/plugins/reporting/server/routes/lib/job_response_handler.ts +++ b/x-pack/plugins/reporting/server/routes/lib/job_response_handler.ts @@ -32,37 +32,42 @@ export function downloadJobResponseHandlerFactory(reporting: ReportingCore) { params: JobResponseHandlerParams, opts: JobResponseHandlerOpts = {} ) { - const { docId } = params; - - const doc = await jobsQuery.get(user, docId, { includeContent: !opts.excludeContent }); - if (!doc) { - return res.notFound(); - } - - const { jobtype: jobType } = doc._source; - - if (!validJobTypes.includes(jobType)) { - return res.unauthorized({ - body: `Sorry, you are not authorized to download ${jobType} reports`, - }); - } - - const payload = getDocumentPayload(doc); - - if (!payload.contentType || !ALLOWED_JOB_CONTENT_TYPES.includes(payload.contentType)) { - return res.badRequest({ - body: `Unsupported content-type of ${payload.contentType} specified by job output`, + try { + const { docId } = params; + + const doc = await jobsQuery.get(user, docId, { includeContent: !opts.excludeContent }); + if (!doc) { + return res.notFound(); + } + + const { jobtype: jobType } = doc._source; + + if (!validJobTypes.includes(jobType)) { + return res.unauthorized({ + body: `Sorry, you are not authorized to download ${jobType} reports`, + }); + } + + const payload = getDocumentPayload(doc); + + if (!payload.contentType || !ALLOWED_JOB_CONTENT_TYPES.includes(payload.contentType)) { + return res.badRequest({ + body: `Unsupported content-type of ${payload.contentType} specified by job output`, + }); + } + + return res.custom({ + body: typeof payload.content === 'string' ? Buffer.from(payload.content) : payload.content, + statusCode: payload.statusCode, + headers: { + ...payload.headers, + 'content-type': payload.contentType || '', + }, }); + } catch (err) { + const { logger } = reporting.getPluginSetupDeps(); + logger.error(err); } - - return res.custom({ - body: typeof payload.content === 'string' ? Buffer.from(payload.content) : payload.content, - statusCode: payload.statusCode, - headers: { - ...payload.headers, - 'content-type': payload.contentType || '', - }, - }); }; } diff --git a/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts b/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts index bd45aa2ab6684..5caf9b798ad1e 100644 --- a/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts +++ b/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts @@ -42,6 +42,7 @@ export const createMockPluginSetup = (setupMock?: any): ReportingInternalSetup = router: setupMock.router, security: setupMock.security, licensing: { license$: Rx.of({ isAvailable: true, isActive: true, type: 'basic' }) } as any, + logger: createMockLevelLogger(), ...setupMock, }; }; @@ -66,6 +67,7 @@ export const createMockPluginStart = ( uiSettings: startMock.uiSettings || { asScopedToClient: () => ({ get: jest.fn() }) }, data: startMock.data || dataPluginMock.createStartContract(), store, + logger: createMockLevelLogger(), ...startMock, }; }; @@ -76,6 +78,7 @@ interface ReportingConfigTestType { queue: Partial; kibanaServer: Partial; csv: Partial; + roles?: Partial; capture: any; server?: any; } @@ -111,6 +114,10 @@ export const createMockConfigSchema = ( csv: { ...overrides.csv, }, + roles: { + enabled: false, + ...overrides.roles, + }, } as any; }; @@ -127,12 +134,12 @@ export const createMockConfig = ( }; export const createMockReportingCore = async ( - config: ReportingConfig, + config: ReportingConfigType, setupDepsMock: ReportingInternalSetup | undefined = undefined, startDepsMock: ReportingInternalStart | undefined = undefined ) => { const mockReportingCore = ({ - getConfig: () => config, + getConfig: () => createMockConfig(config), getEsClient: () => startDepsMock?.esClient, getDataService: () => startDepsMock?.data, } as unknown) as ReportingCore; @@ -145,8 +152,10 @@ export const createMockReportingCore = async ( } const context = coreMock.createPluginInitializerContext(createMockConfigSchema()); - const core = new ReportingCore(logger); - core.setConfig(config); + context.config = { get: () => config } as any; + + const core = new ReportingCore(logger, context); + core.setConfig(createMockConfig(config)); core.pluginSetup(setupDepsMock); await core.pluginSetsUp(); diff --git a/x-pack/plugins/reporting/server/types.ts b/x-pack/plugins/reporting/server/types.ts index f84d2fbdf4712..7ee3403a606a8 100644 --- a/x-pack/plugins/reporting/server/types.ts +++ b/x-pack/plugins/reporting/server/types.ts @@ -36,8 +36,11 @@ export interface ReportingStartDeps { data: DataPluginStart; } -export type ReportingStart = object; -export type ReportingSetup = object; +export interface ReportingSetup { + usesUiCapabilities: () => boolean; +} + +export type ReportingStart = ReportingSetup; /* * Internal Types @@ -97,8 +100,9 @@ export interface ExportTypeDefinition< /** * @internal */ -export interface ReportingRequestHandlerContext extends RequestHandlerContext { +export interface ReportingRequestHandlerContext { reporting: ReportingStart | null; + core: RequestHandlerContext['core']; } /** diff --git a/x-pack/plugins/reporting/server/usage/reporting_usage_collector.test.ts b/x-pack/plugins/reporting/server/usage/reporting_usage_collector.test.ts index 05b80bc8acc75..226704b255ab3 100644 --- a/x-pack/plugins/reporting/server/usage/reporting_usage_collector.test.ts +++ b/x-pack/plugins/reporting/server/usage/reporting_usage_collector.test.ts @@ -9,9 +9,9 @@ import * as Rx from 'rxjs'; import sinon from 'sinon'; import { CollectorFetchContext, UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks'; -import { ReportingConfig, ReportingCore } from '../'; +import { ReportingCore } from '../'; import { getExportTypesRegistry } from '../lib/export_types_registry'; -import { createMockConfig, createMockConfigSchema, createMockReportingCore } from '../test_helpers'; +import { createMockConfigSchema, createMockReportingCore } from '../test_helpers'; import { ReportingSetupDeps } from '../types'; import { FeaturesAvailability } from './'; import { @@ -64,11 +64,9 @@ const getMockFetchClients = (resp: any) => { return fetchParamsMock; }; describe('license checks', () => { - let mockConfig: ReportingConfig; let mockCore: ReportingCore; beforeAll(async () => { - mockConfig = createMockConfig(createMockConfigSchema()); - mockCore = await createMockReportingCore(mockConfig); + mockCore = await createMockReportingCore(createMockConfigSchema()); }); describe('with a basic license', () => { @@ -185,12 +183,10 @@ describe('license checks', () => { }); describe('data modeling', () => { - let mockConfig: ReportingConfig; let mockCore: ReportingCore; let collectorFetchContext: CollectorFetchContext; beforeAll(async () => { - mockConfig = createMockConfig(createMockConfigSchema()); - mockCore = await createMockReportingCore(mockConfig); + mockCore = await createMockReportingCore(createMockConfigSchema()); }); test('with normal looking usage data', async () => { const plugins = getPluginsMock(); @@ -456,8 +452,7 @@ describe('data modeling', () => { describe('Ready for collection observable', () => { test('converts observable to promise', async () => { - const mockConfig = createMockConfig(createMockConfigSchema()); - const mockReporting = await createMockReportingCore(mockConfig); + const mockReporting = await createMockReportingCore(createMockConfigSchema()); const usageCollection = getMockUsageCollection(); const makeCollectorSpy = sinon.spy(); diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 269e6f0c44a0a..11ae755096f0b 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -16,6 +16,7 @@ require('@kbn/test').runTestsCli([ require.resolve('../test/functional/config_security_basic.ts'), require.resolve('../test/reporting_functional/reporting_and_security.config.ts'), require.resolve('../test/reporting_functional/reporting_without_security.config.ts'), + require.resolve('../test/reporting_functional/reporting_and_deprecated_security.config.ts'), require.resolve('../test/security_functional/login_selector.config.ts'), require.resolve('../test/security_functional/oidc.config.ts'), require.resolve('../test/security_functional/saml.config.ts'), diff --git a/x-pack/test/api_integration/apis/security/license_downgrade.ts b/x-pack/test/api_integration/apis/security/license_downgrade.ts index 3017bd005b776..583df6ea5ed07 100644 --- a/x-pack/test/api_integration/apis/security/license_downgrade.ts +++ b/x-pack/test/api_integration/apis/security/license_downgrade.ts @@ -26,6 +26,7 @@ export default function ({ getService }: FtrProviderContext) { 'minimal_read', 'url_create', 'store_search_session', + 'generate_report', ]; const trialPrivileges = await supertest .get('/api/security/privileges') diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts index 9df7eddfd0025..f08712e015656 100644 --- a/x-pack/test/api_integration/apis/security/privileges.ts +++ b/x-pack/test/api_integration/apis/security/privileges.ts @@ -29,8 +29,16 @@ export default function ({ getService }: FtrProviderContext) { 'minimal_read', 'url_create', 'store_search_session', + 'generate_report', + ], + visualize: [ + 'all', + 'read', + 'minimal_all', + 'minimal_read', + 'url_create', + 'generate_report', ], - visualize: ['all', 'read', 'minimal_all', 'minimal_read', 'url_create'], dashboard: [ 'all', 'read', @@ -38,6 +46,8 @@ export default function ({ getService }: FtrProviderContext) { 'minimal_read', 'url_create', 'store_search_session', + 'generate_report', + 'download_csv_report', ], dev_tools: ['all', 'read'], advancedSettings: ['all', 'read'], @@ -47,7 +57,7 @@ export default function ({ getService }: FtrProviderContext) { timelion: ['all', 'read'], graph: ['all', 'read'], maps: ['all', 'read'], - canvas: ['all', 'read'], + canvas: ['all', 'read', 'minimal_all', 'minimal_read', 'generate_report'], infrastructure: ['all', 'read'], logs: ['all', 'read'], uptime: ['all', 'read'], diff --git a/x-pack/test/functional/apps/canvas/feature_controls/canvas_security.ts b/x-pack/test/functional/apps/canvas/feature_controls/canvas_security.ts index b21fba54f1f1a..7f5f5d09f28db 100644 --- a/x-pack/test/functional/apps/canvas/feature_controls/canvas_security.ts +++ b/x-pack/test/functional/apps/canvas/feature_controls/canvas_security.ts @@ -34,7 +34,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { kibana: [ { feature: { - canvas: ['all'], + canvas: ['minimal_all'], }, spaces: ['*'], }, diff --git a/x-pack/test/functional/apps/canvas/reports.ts b/x-pack/test/functional/apps/canvas/reports.ts index 4116a46fe51ae..7edbca783d928 100644 --- a/x-pack/test/functional/apps/canvas/reports.ts +++ b/x-pack/test/functional/apps/canvas/reports.ts @@ -20,7 +20,17 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('Canvas PDF Report Generation', () => { before('initialize tests', async () => { log.debug('ReportingPage:initTests'); - await security.testUser.setRoles(['kibana_admin', 'reporting_user']); + await security.role.create('test_reporting_user', { + elasticsearch: { cluster: [], indices: [], run_as: [] }, + kibana: [ + { + spaces: ['*'], + base: [], + feature: { canvas: ['minimal_read', 'generate_report'] }, + }, + ], + }); + await security.testUser.setRoles(['kibana_admin', 'test_reporting_user']); await esArchiver.load('canvas/reports'); await browser.setWindowSize(1600, 850); }); diff --git a/x-pack/test/functional/apps/dashboard/reporting/screenshots.ts b/x-pack/test/functional/apps/dashboard/reporting/screenshots.ts index b16dc828e1776..a24b18490be74 100644 --- a/x-pack/test/functional/apps/dashboard/reporting/screenshots.ts +++ b/x-pack/test/functional/apps/dashboard/reporting/screenshots.ts @@ -20,6 +20,7 @@ const REPORTS_FOLDER = path.resolve(__dirname, 'reports'); export default function ({ getPageObjects, getService }: FtrProviderContext) { const PageObjects = getPageObjects(['reporting', 'common', 'dashboard']); const esArchiver = getService('esArchiver'); + const security = getService('security'); const browser = getService('browser'); const log = getService('log'); const config = getService('config'); @@ -29,10 +30,32 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { describe('Dashboard Reporting Screenshots', () => { before('initialize tests', async () => { - log.debug('ReportingPage:initTests'); await esArchiver.loadIfNeeded('reporting/ecommerce'); await esArchiver.loadIfNeeded('reporting/ecommerce_kibana'); await browser.setWindowSize(1600, 850); + + await security.role.create('test_reporting_user', { + elasticsearch: { + cluster: [], + indices: [ + { + names: ['ecommerce'], + privileges: ['read'], + field_security: { grant: ['*'], except: [] }, + }, + ], + run_as: [], + }, + kibana: [ + { + spaces: ['*'], + base: [], + feature: { dashboard: ['minimal_all', 'generate_report'] }, + }, + ], + }); + + await security.testUser.setRoles(['test_reporting_user']); }); after('clean up archives', async () => { await esArchiver.unload('reporting/ecommerce'); @@ -42,6 +65,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { refresh: true, body: { query: { match_all: {} } }, }); + await security.testUser.restoreDefaults(); }); describe('Print PDF button', () => { diff --git a/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts b/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts index d595dc98a9a1a..f44d7c42a23c1 100644 --- a/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts +++ b/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts @@ -75,6 +75,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expectSpaceSelector: false, } ); + await PageObjects.common.navigateToApp('discover'); }); after(async () => { @@ -87,12 +88,11 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect(navLinks.map((link) => link.text)).to.eql([ 'Overview', 'Discover', - 'Stack Management', // because `global_discover_all_role` enables search sessions + 'Stack Management', // because `global_discover_all_role` enables search sessions and reporting ]); }); it('shows save button', async () => { - await PageObjects.common.navigateToApp('discover'); await testSubjects.existOrFail('discoverSaveButton', { timeout: 20000 }); }); @@ -107,6 +107,12 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.share.clickShareTopNavButton(); }); + it('shows CSV reports', async () => { + await PageObjects.share.clickShareTopNavButton(); + await testSubjects.existOrFail('sharePanel-CSVReports'); + await PageObjects.share.clickShareTopNavButton(); + }); + it('allows saving via the saved query management component popover with no saved query loaded', async () => { await queryBar.setQuery('response:200'); await savedQueryManagementComponent.saveNewQuery('foo', 'bar', true, false); @@ -213,8 +219,15 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); it(`Permalinks doesn't show create short-url button`, async () => { - await PageObjects.share.openShareMenuItem('Permalinks'); + await PageObjects.share.clickShareTopNavButton(); await PageObjects.share.createShortUrlMissingOrFail(); + await PageObjects.share.clickShareTopNavButton(); + }); + + it(`doesn't show CSV reports`, async () => { + await PageObjects.share.clickShareTopNavButton(); + await testSubjects.missingOrFail('sharePanel-CSVReports'); + await PageObjects.share.clickShareTopNavButton(); }); it('allows loading a saved query via the saved query management component', async () => { @@ -304,7 +317,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); it('Permalinks shows create short-url button', async () => { - await PageObjects.share.openShareMenuItem('Permalinks'); + await PageObjects.share.clickShareTopNavButton(); await PageObjects.share.createShortUrlExistOrFail(); // close the menu await PageObjects.share.clickShareTopNavButton(); diff --git a/x-pack/test/functional/apps/lens/lens_reporting.ts b/x-pack/test/functional/apps/lens/lens_reporting.ts index e8f1916a3630c..658a9dbcac822 100644 --- a/x-pack/test/functional/apps/lens/lens_reporting.ts +++ b/x-pack/test/functional/apps/lens/lens_reporting.ts @@ -18,8 +18,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('lens reporting', () => { before(async () => { await esArchiver.loadIfNeeded('lens/reporting'); + await security.role.create('test_reporting_user', { + elasticsearch: { cluster: [], indices: [], run_as: [] }, + kibana: [ + { + spaces: ['*'], + base: [], + feature: { dashboard: ['minimal_read', 'generate_report'] }, + }, + ], + }); await security.testUser.setRoles( - ['test_logstash_reader', 'global_dashboard_read', 'reporting_user'], + ['test_logstash_reader', 'global_dashboard_read', 'test_reporting_user'], false ); }); diff --git a/x-pack/test/functional/apps/management/feature_controls/management_security.ts b/x-pack/test/functional/apps/management/feature_controls/management_security.ts index 7d121e9100749..24d3455219fe5 100644 --- a/x-pack/test/functional/apps/management/feature_controls/management_security.ts +++ b/x-pack/test/functional/apps/management/feature_controls/management_security.ts @@ -64,7 +64,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect(sections).to.have.length(2); expect(sections[0]).to.eql({ sectionId: 'insightsAndAlerting', - sectionLinks: ['triggersActions'], + sectionLinks: ['triggersActions', 'reporting'], }); expect(sections[1]).to.eql({ sectionId: 'kibana', diff --git a/x-pack/test/functional/apps/reporting_management/report_listing.ts b/x-pack/test/functional/apps/reporting_management/report_listing.ts index 964e6485aff0b..e6503b1550001 100644 --- a/x-pack/test/functional/apps/reporting_management/report_listing.ts +++ b/x-pack/test/functional/apps/reporting_management/report_listing.ts @@ -19,7 +19,17 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { describe('Listing of Reports', function () { before(async () => { - await security.testUser.setRoles(['kibana_admin', 'reporting_user']); + await security.role.create('test_reporting_user', { + elasticsearch: { cluster: [], indices: [], run_as: [] }, + kibana: [ + { + spaces: ['*'], + base: [], + feature: { canvas: ['minimal_read', 'generate_report'] }, + }, + ], + }); + await security.testUser.setRoles(['kibana_admin', 'test_reporting_user']); await esArchiver.load('empty_kibana'); }); diff --git a/x-pack/test/functional/apps/saved_objects_management/feature_controls/saved_objects_management_security.ts b/x-pack/test/functional/apps/saved_objects_management/feature_controls/saved_objects_management_security.ts index 95ebc7b2ff5d5..d9ba3a78eff13 100644 --- a/x-pack/test/functional/apps/saved_objects_management/feature_controls/saved_objects_management_security.ts +++ b/x-pack/test/functional/apps/saved_objects_management/feature_controls/saved_objects_management_security.ts @@ -262,7 +262,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { kibana: [ { feature: { - visualize: ['all'], + visualize: ['minimal_all'], }, spaces: ['*'], }, diff --git a/x-pack/test/functional/apps/security/secure_roles_perm.js b/x-pack/test/functional/apps/security/secure_roles_perm.js index a1f258714bb0d..33913bcbbf7f0 100644 --- a/x-pack/test/functional/apps/security/secure_roles_perm.js +++ b/x-pack/test/functional/apps/security/secure_roles_perm.js @@ -14,7 +14,7 @@ export default function ({ getService, getPageObjects }) { 'monitoring', 'discover', 'common', - 'reporting', + 'share', 'header', ]); const log = getService('log'); @@ -59,13 +59,13 @@ export default function ({ getService, getPageObjects }) { confirm_password: 'changeme', full_name: 'RashmiFirst RashmiLast', email: 'rashmi@myEmail.com', - roles: ['logstash_reader', 'kibana_admin'], + roles: ['logstash_reader'], }); log.debug('After Add user: , userObj.userName'); const users = keyBy(await PageObjects.security.getElasticsearchUsers(), 'username'); log.debug('actualUsers = %j', users); log.debug('roles: ', users.Rashmi.roles); - expect(users.Rashmi.roles).to.eql(['logstash_reader', 'kibana_admin']); + expect(users.Rashmi.roles).to.eql(['logstash_reader']); expect(users.Rashmi.fullname).to.eql('RashmiFirst RashmiLast'); expect(users.Rashmi.reserved).to.be(false); await PageObjects.security.forceLogout(); @@ -77,14 +77,12 @@ export default function ({ getService, getPageObjects }) { await testSubjects.missingOrFail('users'); }); - it('Kibana User navigating to Discover and trying to generate CSV gets - Authorization Error ', async function () { + it('Kibana User navigating to Discover sees the generate CSV button', async function () { await PageObjects.common.navigateToApp('discover'); await PageObjects.discover.loadSavedSearch('A Saved Search'); - log.debug('click Reporting button'); - await PageObjects.reporting.openCsvReportingPanel(); - await PageObjects.reporting.clickGenerateReportButton(); - const queueReportError = await PageObjects.reporting.getQueueReportError(); - expect(queueReportError).to.be(true); + log.debug('click Top Nav Share button'); + await PageObjects.share.clickShareTopNavButton(); + await testSubjects.existOrFail('sharePanel-CSVReports'); }); after(async function () { diff --git a/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts b/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts index d6644cee21198..f650ac08de166 100644 --- a/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts +++ b/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts @@ -81,7 +81,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('shows visualize navlink', async () => { const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).to.eql(['Overview', 'Visualize Library']); + expect(navLinks).to.eql(['Overview', 'Visualize Library', 'Stack Management']); }); it(`landing page shows "Create new Visualization" button`, async () => { diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index 1cf3701aa7d2d..57d35590b3c89 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -84,6 +84,7 @@ export default async function ({ readConfigFile }) { '--xpack.maps.showMapsInspectorAdapter=true', '--xpack.maps.preserveDrawingBuffer=true', '--xpack.maps.enableDrawingFeature=true', + '--xpack.reporting.roles.enabled=false', // use the non-deprecated access control model for Reporting '--xpack.reporting.queue.pollInterval=3000', // make it explicitly the default '--xpack.reporting.csv.maxSizeBytes=2850', // small-ish limit for cutting off a 1999 byte report '--usageCollection.maximumWaitTimeForAllCollectorsInS=1', @@ -237,8 +238,8 @@ export default async function ({ readConfigFile }) { kibana: [ { feature: { - canvas: ['all'], - visualize: ['all'], + canvas: ['minimal_all'], + visualize: ['minimal_all'], }, spaces: ['*'], }, diff --git a/x-pack/test/licensing_plugin/public/updates.ts b/x-pack/test/licensing_plugin/public/updates.ts index e09eb04065b64..d7442e491875a 100644 --- a/x-pack/test/licensing_plugin/public/updates.ts +++ b/x-pack/test/licensing_plugin/public/updates.ts @@ -19,7 +19,8 @@ export default function (ftrContext: FtrProviderContext) { const scenario = createScenario(ftrContext); - describe('changes in license types', () => { + // FLAKY: https://github.com/elastic/kibana/issues/53575 + describe.skip('changes in license types', () => { after(async () => { await scenario.teardown(); }); @@ -34,7 +35,7 @@ export default function (ftrContext: FtrProviderContext) { // this call enforces signature check to detect license update // and causes license re-fetch await setup.core.http.get('/'); - await testUtils.delay(500); + await testUtils.delay(1000); const licensing: LicensingPluginSetup = setup.plugins.licensing; licensing.license$.subscribe((license) => cb(license.type)); @@ -50,7 +51,7 @@ export default function (ftrContext: FtrProviderContext) { // this call enforces signature check to detect license update // and causes license re-fetch await setup.core.http.get('/'); - await testUtils.delay(500); + await testUtils.delay(1000); const licensing: LicensingPluginSetup = setup.plugins.licensing; licensing.license$.subscribe((license) => cb(license.type)); @@ -66,7 +67,7 @@ export default function (ftrContext: FtrProviderContext) { // this call enforces signature check to detect license update // and causes license re-fetch await setup.core.http.get('/'); - await testUtils.delay(500); + await testUtils.delay(1000); const licensing: LicensingPluginSetup = setup.plugins.licensing; licensing.license$.subscribe((license) => cb(license.type)); @@ -82,7 +83,7 @@ export default function (ftrContext: FtrProviderContext) { // this call enforces signature check to detect license update // and causes license re-fetch await setup.core.http.get('/'); - await testUtils.delay(500); + await testUtils.delay(1000); const licensing: LicensingPluginSetup = setup.plugins.licensing; licensing.license$.subscribe((license) => cb(license.type)); diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/index.ts b/x-pack/test/reporting_api_integration/reporting_and_security/index.ts index ef1bd03d805df..e33efff0c6bf1 100644 --- a/x-pack/test/reporting_api_integration/reporting_and_security/index.ts +++ b/x-pack/test/reporting_api_integration/reporting_and_security/index.ts @@ -15,6 +15,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { before(async () => { const reportingAPI = getService('reportingAPI'); await reportingAPI.createDataAnalystRole(); + await reportingAPI.createTestReportingUserRole(); await reportingAPI.createDataAnalyst(); await reportingAPI.createTestReportingUser(); }); diff --git a/x-pack/test/reporting_api_integration/services/scenarios.ts b/x-pack/test/reporting_api_integration/services/scenarios.ts index d13deac3578ba..eee13b0bf07a2 100644 --- a/x-pack/test/reporting_api_integration/services/scenarios.ts +++ b/x-pack/test/reporting_api_integration/services/scenarios.ts @@ -58,6 +58,35 @@ export function createScenarios({ getService }: Pick { + await security.role.create('test_reporting_user', { + metadata: {}, + elasticsearch: { + cluster: [], + indices: [ + { + names: ['ecommerce'], + privileges: ['read', 'view_index_metadata'], + allow_restricted_indices: false, + }, + ], + run_as: [], + }, + kibana: [ + { + base: [], + feature: { + 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', @@ -69,7 +98,7 @@ export function createScenarios({ getService }: Pick { await security.user.create('reporting_user', { password: 'reporting_user-password', - roles: ['data_analyst', 'reporting_user'], + roles: ['test_reporting_user'], full_name: 'Reporting User', }); }; @@ -142,6 +171,7 @@ export function createScenarios({ getService }: Pick { + await security.role.create('data_analyst', { + metadata: {}, + elasticsearch: { + cluster: [], + indices: [ + { + names: ['ecommerce'], + privileges: ['read', 'view_index_metadata'], + allow_restricted_indices: false, + }, + ], + run_as: [], + }, + kibana: [{ base: ['all'], feature: {}, spaces: ['*'] }], + }); + }; + const createDataAnalyst = async () => { + await security.user.create('data_analyst', { + password: 'data_analyst-password', + roles: ['data_analyst', 'kibana_user'], + full_name: 'a kibana user called data_a', + }); + }; + const createReportingUser = async () => { + await security.user.create('reporting_user', { + password: 'reporting_user-password', + roles: ['reporting_user', 'data_analyst', 'kibana_user'], // Deprecated: using built-in `reporting_user` role grants all Reporting privileges + full_name: 'a reporting user', + }); + }; + + describe('Reporting Functional Tests with Deprecated Security configuration enabled', function () { + this.tags('ciGroup2'); + + before(async () => { + await createDataAnalystRole(); + await createDataAnalyst(); + await createReportingUser(); + }); + + const { loadTestFile } = context; + loadTestFile(require.resolve('./security_roles_privileges')); + loadTestFile(require.resolve('./management')); + }); +} diff --git a/x-pack/test/reporting_functional/reporting_and_deprecated_security/management.ts b/x-pack/test/reporting_functional/reporting_and_deprecated_security/management.ts new file mode 100644 index 0000000000000..dba16c798d4ff --- /dev/null +++ b/x-pack/test/reporting_functional/reporting_and_deprecated_security/management.ts @@ -0,0 +1,37 @@ +/* + * 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 { FtrProviderContext } from '../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService, getPageObjects }: FtrProviderContext) => { + const PageObjects = getPageObjects(['common', 'reporting', 'discover']); + + const testSubjects = getService('testSubjects'); + const reportingFunctional = getService('reportingFunctional'); + + describe('Access to Management > Reporting', () => { + before(async () => { + await reportingFunctional.initEcommerce(); + }); + after(async () => { + await reportingFunctional.teardownEcommerce(); + }); + + it('does not allow user that does not have reporting_user role', async () => { + await reportingFunctional.loginDataAnalyst(); + await PageObjects.common.navigateToApp('reporting'); + await testSubjects.missingOrFail('reportJobListing'); + }); + + it('does allow user with reporting_user role', async () => { + await reportingFunctional.loginReportingUser(); + await PageObjects.common.navigateToApp('reporting'); + await testSubjects.existOrFail('reportJobListing'); + }); + }); +}; diff --git a/x-pack/test/reporting_functional/reporting_and_deprecated_security/security_roles_privileges.ts b/x-pack/test/reporting_functional/reporting_and_deprecated_security/security_roles_privileges.ts new file mode 100644 index 0000000000000..76ccb01477856 --- /dev/null +++ b/x-pack/test/reporting_functional/reporting_and_deprecated_security/security_roles_privileges.ts @@ -0,0 +1,109 @@ +/* + * 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 { FtrProviderContext } from '../ftr_provider_context'; + +const DASHBOARD_TITLE = 'Ecom Dashboard'; +const SAVEDSEARCH_TITLE = 'Ecommerce Data'; +const VIS_TITLE = 'e-commerce pie chart'; +const CANVAS_TITLE = 'The Very Cool Workpad for PDF Tests'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const reportingFunctional = getService('reportingFunctional'); + + describe('Security with `reporting_user` built-in role', () => { + before(async () => { + await reportingFunctional.initEcommerce(); + }); + after(async () => { + await reportingFunctional.teardownEcommerce(); + }); + + describe('Dashboard: Download CSV file', () => { + it('does not allow user that does not have reporting_user role', async () => { + await reportingFunctional.loginDataAnalyst(); + await reportingFunctional.openSavedDashboard(DASHBOARD_TITLE); + await reportingFunctional.tryDashboardDownloadCsvFail('Ecommerce Data'); + }); + + it('does allow user with reporting_user role', async () => { + await reportingFunctional.loginDataAnalyst(); + await reportingFunctional.openSavedDashboard(DASHBOARD_TITLE); + await reportingFunctional.tryDashboardDownloadCsvSuccess('Ecommerce Data'); + }); + }); + + describe('Dashboard: Generate Screenshot', () => { + it('does not allow user that does not have reporting_user role', async () => { + await reportingFunctional.loginDataAnalyst(); + await reportingFunctional.openSavedDashboard(DASHBOARD_TITLE); + await reportingFunctional.tryGeneratePdfFail(); + }); + + it('does allow user with reporting_user role', async () => { + await reportingFunctional.loginReportingUser(); + await reportingFunctional.openSavedDashboard(DASHBOARD_TITLE); + await reportingFunctional.tryGeneratePdfSuccess(); + }); + }); + + describe('Discover: Generate CSV', () => { + it('does not allow user that does not have reporting_user role', async () => { + await reportingFunctional.loginDataAnalyst(); + await reportingFunctional.openSavedSearch(SAVEDSEARCH_TITLE); + await reportingFunctional.tryDiscoverCsvFail(); + }); + + it('does allow user with reporting_user role', async () => { + await reportingFunctional.loginReportingUser(); + await reportingFunctional.openSavedSearch(SAVEDSEARCH_TITLE); + await reportingFunctional.tryDiscoverCsvSuccess(); + }); + }); + + describe('Canvas: Generate PDF', () => { + const esArchiver = getService('esArchiver'); + const reportingApi = getService('reportingAPI'); + before('initialize tests', async () => { + await esArchiver.load('canvas/reports'); + }); + + after('teardown tests', async () => { + await esArchiver.unload('canvas/reports'); + await reportingApi.deleteAllReports(); + await reportingFunctional.initEcommerce(); + }); + + it('does not allow user that does not have reporting_user role', async () => { + await reportingFunctional.loginDataAnalyst(); + await reportingFunctional.openCanvasWorkpad(CANVAS_TITLE); + await reportingFunctional.tryGeneratePdfFail(); + }); + + it('does allow user with reporting_user role', async () => { + await reportingFunctional.loginReportingUser(); + await reportingFunctional.openCanvasWorkpad(CANVAS_TITLE); + await reportingFunctional.tryGeneratePdfSuccess(); + }); + }); + + describe('Visualize Editor: Generate Screenshot', () => { + it('does not allow user that does not have reporting_user role', async () => { + await reportingFunctional.loginDataAnalyst(); + await reportingFunctional.openSavedVisualization(VIS_TITLE); + await reportingFunctional.tryGeneratePdfFail(); + }); + + it('does allow user with reporting_user role', async () => { + await reportingFunctional.loginReportingUser(); + await reportingFunctional.openSavedVisualization(VIS_TITLE); + await reportingFunctional.tryGeneratePdfSuccess(); + }); + }); + }); +} diff --git a/x-pack/test/reporting_functional/reporting_and_security/index.ts b/x-pack/test/reporting_functional/reporting_and_security/index.ts index f3e01453b0a59..be0e76a28bd0b 100644 --- a/x-pack/test/reporting_functional/reporting_and_security/index.ts +++ b/x-pack/test/reporting_functional/reporting_and_security/index.ts @@ -9,46 +9,15 @@ import { FtrProviderContext } from '../ftr_provider_context'; // eslint-disable-next-line import/no-default-export export default function ({ getService, loadTestFile }: FtrProviderContext) { - const security = getService('security'); - const createDataAnalystRole = async () => { - await security.role.create('data_analyst', { - metadata: {}, - elasticsearch: { - cluster: [], - indices: [ - { - names: ['ecommerce'], - privileges: ['read', 'view_index_metadata'], - allow_restricted_indices: false, - }, - ], - run_as: [], - }, - kibana: [{ base: ['all'], feature: {}, spaces: ['*'] }], - }); - }; - const createDataAnalyst = async () => { - await security.user.create('data_analyst', { - password: 'data_analyst-password', - roles: ['data_analyst', 'kibana_user'], - full_name: 'a kibana user called data_a', - }); - }; - const createReportingUser = async () => { - await security.user.create('reporting_user', { - password: 'reporting_user-password', - roles: ['reporting_user', 'data_analyst', 'kibana_user'], - full_name: 'a reporting user', - }); - }; - - describe('Reporting Functional Tests with Role-based Security configuration enabled', function () { + describe('Reporting Functional Tests with Security enabled', function () { this.tags('ciGroup2'); before(async () => { - await createDataAnalystRole(); - await createDataAnalyst(); - await createReportingUser(); + const reportingFunctional = getService('reportingFunctional'); + await reportingFunctional.createDataAnalystRole(); + await reportingFunctional.createDataAnalyst(); + await reportingFunctional.createTestReportingUserRole(); + await reportingFunctional.createTestReportingUser(); }); loadTestFile(require.resolve('./security_roles_privileges')); diff --git a/x-pack/test/reporting_functional/reporting_and_security/management.ts b/x-pack/test/reporting_functional/reporting_and_security/management.ts index dba16c798d4ff..304c175f0cb5d 100644 --- a/x-pack/test/reporting_functional/reporting_and_security/management.ts +++ b/x-pack/test/reporting_functional/reporting_and_security/management.ts @@ -9,8 +9,7 @@ import { FtrProviderContext } from '../ftr_provider_context'; // eslint-disable-next-line import/no-default-export export default ({ getService, getPageObjects }: FtrProviderContext) => { - const PageObjects = getPageObjects(['common', 'reporting', 'discover']); - + const PageObjects = getPageObjects(['common']); const testSubjects = getService('testSubjects'); const reportingFunctional = getService('reportingFunctional'); @@ -22,13 +21,13 @@ export default ({ getService, getPageObjects }: FtrProviderContext) => { await reportingFunctional.teardownEcommerce(); }); - it('does not allow user that does not have reporting_user role', async () => { + it('does not allow user that does not have reporting privileges', async () => { await reportingFunctional.loginDataAnalyst(); await PageObjects.common.navigateToApp('reporting'); await testSubjects.missingOrFail('reportJobListing'); }); - it('does allow user with reporting_user role', async () => { + it('does allow user with reporting privileges', async () => { await reportingFunctional.loginReportingUser(); await PageObjects.common.navigateToApp('reporting'); await testSubjects.existOrFail('reportJobListing'); diff --git a/x-pack/test/reporting_functional/reporting_and_security/security_roles_privileges.ts b/x-pack/test/reporting_functional/reporting_and_security/security_roles_privileges.ts index 76ccb01477856..20b88b22b542c 100644 --- a/x-pack/test/reporting_functional/reporting_and_security/security_roles_privileges.ts +++ b/x-pack/test/reporting_functional/reporting_and_security/security_roles_privileges.ts @@ -25,41 +25,47 @@ export default function ({ getService }: FtrProviderContext) { }); describe('Dashboard: Download CSV file', () => { - it('does not allow user that does not have reporting_user role', async () => { + it('does not allow user that does not have reporting privileges', async () => { await reportingFunctional.loginDataAnalyst(); await reportingFunctional.openSavedDashboard(DASHBOARD_TITLE); - await reportingFunctional.tryDashboardDownloadCsvFail('Ecommerce Data'); + await reportingFunctional.tryDashboardDownloadCsvNotAvailable('Ecommerce Data'); }); - it('does allow user with reporting_user role', async () => { - await reportingFunctional.loginDataAnalyst(); + it('does allow user with reporting privileges', async () => { + await reportingFunctional.loginReportingUser(); await reportingFunctional.openSavedDashboard(DASHBOARD_TITLE); await reportingFunctional.tryDashboardDownloadCsvSuccess('Ecommerce Data'); }); }); describe('Dashboard: Generate Screenshot', () => { - it('does not allow user that does not have reporting_user role', async () => { + it('does not allow user that does not have reporting privileges', async () => { await reportingFunctional.loginDataAnalyst(); await reportingFunctional.openSavedDashboard(DASHBOARD_TITLE); - await reportingFunctional.tryGeneratePdfFail(); + await reportingFunctional.tryReportsNotAvailable(); }); - it('does allow user with reporting_user role', async () => { + it('does allow PDF generation user with reporting privileges', async () => { await reportingFunctional.loginReportingUser(); await reportingFunctional.openSavedDashboard(DASHBOARD_TITLE); await reportingFunctional.tryGeneratePdfSuccess(); }); + + it('does allow PNG generation user with reporting privileges', async () => { + await reportingFunctional.loginReportingUser(); + await reportingFunctional.openSavedDashboard(DASHBOARD_TITLE); + await reportingFunctional.tryGeneratePngSuccess(); + }); }); describe('Discover: Generate CSV', () => { - it('does not allow user that does not have reporting_user role', async () => { + it('does not allow user that does not have reporting privileges', async () => { await reportingFunctional.loginDataAnalyst(); await reportingFunctional.openSavedSearch(SAVEDSEARCH_TITLE); - await reportingFunctional.tryDiscoverCsvFail(); + await reportingFunctional.tryDiscoverCsvNotAvailable(); }); - it('does allow user with reporting_user role', async () => { + it('does allow user with reporting privileges', async () => { await reportingFunctional.loginReportingUser(); await reportingFunctional.openSavedSearch(SAVEDSEARCH_TITLE); await reportingFunctional.tryDiscoverCsvSuccess(); @@ -79,13 +85,13 @@ export default function ({ getService }: FtrProviderContext) { await reportingFunctional.initEcommerce(); }); - it('does not allow user that does not have reporting_user role', async () => { + it('does not allow user that does not have reporting privileges', async () => { await reportingFunctional.loginDataAnalyst(); await reportingFunctional.openCanvasWorkpad(CANVAS_TITLE); - await reportingFunctional.tryGeneratePdfFail(); + await reportingFunctional.tryGeneratePdfNotAvailable(); }); - it('does allow user with reporting_user role', async () => { + it('does allow user with reporting privileges', async () => { await reportingFunctional.loginReportingUser(); await reportingFunctional.openCanvasWorkpad(CANVAS_TITLE); await reportingFunctional.tryGeneratePdfSuccess(); @@ -93,17 +99,23 @@ export default function ({ getService }: FtrProviderContext) { }); describe('Visualize Editor: Generate Screenshot', () => { - it('does not allow user that does not have reporting_user role', async () => { + it('does not allow user that does not have reporting privileges', async () => { await reportingFunctional.loginDataAnalyst(); await reportingFunctional.openSavedVisualization(VIS_TITLE); - await reportingFunctional.tryGeneratePdfFail(); + await reportingFunctional.tryReportsNotAvailable(); }); - it('does allow user with reporting_user role', async () => { + it('does allow PDF generation user with reporting privileges', async () => { await reportingFunctional.loginReportingUser(); await reportingFunctional.openSavedVisualization(VIS_TITLE); await reportingFunctional.tryGeneratePdfSuccess(); }); + + it('does allow PNG generation user with reporting privileges', async () => { + await reportingFunctional.loginReportingUser(); + await reportingFunctional.openSavedVisualization(VIS_TITLE); + await reportingFunctional.tryGeneratePngSuccess(); + }); }); }); } diff --git a/x-pack/test/security_api_integration/tests/anonymous/capabilities.ts b/x-pack/test/security_api_integration/tests/anonymous/capabilities.ts index db9715f7e48d5..47d942db947f5 100644 --- a/x-pack/test/security_api_integration/tests/anonymous/capabilities.ts +++ b/x-pack/test/security_api_integration/tests/anonymous/capabilities.ts @@ -56,6 +56,8 @@ export default function ({ getService }: FtrProviderContext) { "dashboard": Object { "createNew": false, "createShortUrl": false, + "downloadCsv": false, + "generateScreenshot": false, "saveQuery": false, "show": false, "showWriteControls": false, @@ -63,6 +65,7 @@ export default function ({ getService }: FtrProviderContext) { }, "discover": Object { "createShortUrl": false, + "generateCsv": false, "save": false, "saveQuery": false, "show": false, @@ -76,6 +79,7 @@ export default function ({ getService }: FtrProviderContext) { "visualize": Object { "createShortUrl": false, "delete": false, + "generateScreenshot": false, "save": false, "saveQuery": false, "show": false, @@ -87,6 +91,8 @@ export default function ({ getService }: FtrProviderContext) { "dashboard": Object { "createNew": false, "createShortUrl": false, + "downloadCsv": false, + "generateScreenshot": false, "saveQuery": false, "show": false, "showWriteControls": false, @@ -94,6 +100,7 @@ export default function ({ getService }: FtrProviderContext) { }, "discover": Object { "createShortUrl": false, + "generateCsv": false, "save": false, "saveQuery": false, "show": false, @@ -107,6 +114,7 @@ export default function ({ getService }: FtrProviderContext) { "visualize": Object { "createShortUrl": false, "delete": false, + "generateScreenshot": false, "save": false, "saveQuery": false, "show": false, @@ -118,6 +126,8 @@ export default function ({ getService }: FtrProviderContext) { "dashboard": Object { "createNew": false, "createShortUrl": false, + "downloadCsv": false, + "generateScreenshot": false, "saveQuery": false, "show": false, "showWriteControls": false, @@ -125,6 +135,7 @@ export default function ({ getService }: FtrProviderContext) { }, "discover": Object { "createShortUrl": false, + "generateCsv": false, "save": false, "saveQuery": false, "show": false, @@ -138,6 +149,7 @@ export default function ({ getService }: FtrProviderContext) { "visualize": Object { "createShortUrl": false, "delete": false, + "generateScreenshot": false, "save": false, "saveQuery": false, "show": false, @@ -168,6 +180,8 @@ export default function ({ getService }: FtrProviderContext) { "dashboard": Object { "createNew": false, "createShortUrl": false, + "downloadCsv": false, + "generateScreenshot": false, "saveQuery": false, "show": false, "showWriteControls": false, @@ -175,6 +189,7 @@ export default function ({ getService }: FtrProviderContext) { }, "discover": Object { "createShortUrl": false, + "generateCsv": false, "save": false, "saveQuery": false, "show": false, @@ -188,6 +203,7 @@ export default function ({ getService }: FtrProviderContext) { "visualize": Object { "createShortUrl": false, "delete": false, + "generateScreenshot": false, "save": false, "saveQuery": false, "show": false, @@ -199,6 +215,8 @@ export default function ({ getService }: FtrProviderContext) { "dashboard": Object { "createNew": false, "createShortUrl": false, + "downloadCsv": false, + "generateScreenshot": false, "saveQuery": false, "show": false, "showWriteControls": false, @@ -206,6 +224,7 @@ export default function ({ getService }: FtrProviderContext) { }, "discover": Object { "createShortUrl": false, + "generateCsv": false, "save": false, "saveQuery": false, "show": false, @@ -219,6 +238,7 @@ export default function ({ getService }: FtrProviderContext) { "visualize": Object { "createShortUrl": false, "delete": false, + "generateScreenshot": false, "save": false, "saveQuery": false, "show": false, @@ -230,6 +250,8 @@ export default function ({ getService }: FtrProviderContext) { "dashboard": Object { "createNew": false, "createShortUrl": false, + "downloadCsv": false, + "generateScreenshot": false, "saveQuery": false, "show": false, "showWriteControls": false, @@ -237,6 +259,7 @@ export default function ({ getService }: FtrProviderContext) { }, "discover": Object { "createShortUrl": false, + "generateCsv": false, "save": false, "saveQuery": false, "show": false, @@ -250,6 +273,7 @@ export default function ({ getService }: FtrProviderContext) { "visualize": Object { "createShortUrl": false, "delete": false, + "generateScreenshot": false, "save": false, "saveQuery": false, "show": false, @@ -298,6 +322,8 @@ export default function ({ getService }: FtrProviderContext) { "dashboard": Object { "createNew": false, "createShortUrl": false, + "downloadCsv": false, + "generateScreenshot": false, "saveQuery": false, "show": true, "showWriteControls": false, @@ -305,6 +331,7 @@ export default function ({ getService }: FtrProviderContext) { }, "discover": Object { "createShortUrl": false, + "generateCsv": false, "save": false, "saveQuery": false, "show": true, @@ -318,6 +345,7 @@ export default function ({ getService }: FtrProviderContext) { "visualize": Object { "createShortUrl": false, "delete": false, + "generateScreenshot": false, "save": false, "saveQuery": false, "show": true, @@ -331,6 +359,8 @@ export default function ({ getService }: FtrProviderContext) { "dashboard": Object { "createNew": false, "createShortUrl": false, + "downloadCsv": false, + "generateScreenshot": false, "saveQuery": false, "show": false, "showWriteControls": false, @@ -338,6 +368,7 @@ export default function ({ getService }: FtrProviderContext) { }, "discover": Object { "createShortUrl": false, + "generateCsv": false, "save": false, "saveQuery": false, "show": false, @@ -351,6 +382,7 @@ export default function ({ getService }: FtrProviderContext) { "visualize": Object { "createShortUrl": false, "delete": false, + "generateScreenshot": false, "save": false, "saveQuery": false, "show": false, @@ -364,6 +396,8 @@ export default function ({ getService }: FtrProviderContext) { "dashboard": Object { "createNew": false, "createShortUrl": false, + "downloadCsv": false, + "generateScreenshot": false, "saveQuery": false, "show": false, "showWriteControls": false, @@ -371,6 +405,7 @@ export default function ({ getService }: FtrProviderContext) { }, "discover": Object { "createShortUrl": false, + "generateCsv": false, "save": false, "saveQuery": false, "show": false, @@ -384,6 +419,7 @@ export default function ({ getService }: FtrProviderContext) { "visualize": Object { "createShortUrl": false, "delete": false, + "generateScreenshot": false, "save": false, "saveQuery": false, "show": true,