diff --git a/docs/developer/architecture/security/feature-registration.asciidoc b/docs/developer/architecture/security/feature-registration.asciidoc index 8c80c2e5f2ffb..4e0c220477faf 100644 --- a/docs/developer/architecture/security/feature-registration.asciidoc +++ b/docs/developer/architecture/security/feature-registration.asciidoc @@ -198,7 +198,10 @@ server.route({ === Example 3: Discover Discover takes advantage of subfeature privileges to allow fine-grained access control. In this example, -a single "Create Short URLs" subfeature privilege is defined, which allows users to grant access to this feature without having to grant the `all` privilege to Discover. In other words, you can grant `read` access to Discover, and also grant the ability to create short URLs. +two subfeature privileges are defined: "Create Short URLs", and "Generate PDF Reports". These allow users to grant access to this feature without having to grant the `all` privilege to Discover. In other words, you can grant `read` access to Discover, and also grant the ability to create short URLs or generate PDF reports. + +Notice the "Generate PDF Reports" subfeature privilege has an additional `minimumPrivilege` option. Kibana will only offer this subfeature privilege if the +license requirement is satisfied. ["source","javascript"] ----------- @@ -259,6 +262,28 @@ public setup(core, { features }) { }, ], }, + { + groupType: 'independent', + privileges: [ + { + id: 'pdf_generate', + name: i18n.translate( + 'xpack.features.ossFeatures.discoverGeneratePDFReportsPrivilegeName', + { + defaultMessage: 'Generate PDF Reports', + } + ), + minimumLicense: 'platinum', + includeIn: 'all', + savedObject: { + all: [], + read: [], + }, + api: ['generatePDFReports'], + ui: ['generatePDFReports'], + }, + ], + }, ], }, ], diff --git a/packages/kbn-telemetry-tools/src/tools/__fixture__/all_extracted_collectors.ts b/packages/kbn-telemetry-tools/src/tools/__fixture__/all_extracted_collectors.ts new file mode 100644 index 0000000000000..f531608dda50a --- /dev/null +++ b/packages/kbn-telemetry-tools/src/tools/__fixture__/all_extracted_collectors.ts @@ -0,0 +1,37 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { parsedExternallyDefinedCollector } from './parsed_externally_defined_collector'; +import { parsedImportedSchemaCollector } from './parsed_imported_schema'; +import { parsedImportedUsageInterface } from './parsed_imported_usage_interface'; +import { parsedIndexedInterfaceWithNoMatchingSchema } from './parsed_indexed_interface_with_not_matching_schema'; +import { parsedNestedCollector } from './parsed_nested_collector'; +import { parsedSchemaDefinedWithSpreadsCollector } from './parsed_schema_defined_with_spreads_collector'; +import { parsedWorkingCollector } from './parsed_working_collector'; +import { ParsedUsageCollection } from '../ts_parser'; + +export const allExtractedCollectors: ParsedUsageCollection[] = [ + ...parsedExternallyDefinedCollector, + ...parsedImportedSchemaCollector, + ...parsedImportedUsageInterface, + parsedIndexedInterfaceWithNoMatchingSchema, + parsedNestedCollector, + parsedSchemaDefinedWithSpreadsCollector, + parsedWorkingCollector, +]; diff --git a/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_indexed_interface_with_not_matching_schema.ts b/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_indexed_interface_with_not_matching_schema.ts index 109fc045b6ee0..572684fbe83fb 100644 --- a/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_indexed_interface_with_not_matching_schema.ts +++ b/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_indexed_interface_with_not_matching_schema.ts @@ -36,16 +36,14 @@ export const parsedIndexedInterfaceWithNoMatchingSchema: ParsedUsageCollection = fetch: { typeName: 'Usage', typeDescriptor: { - '': { - '@@INDEX@@': { - count_1: { - kind: SyntaxKind.NumberKeyword, - type: 'NumberKeyword', - }, - count_2: { - kind: SyntaxKind.NumberKeyword, - type: 'NumberKeyword', - }, + '@@INDEX@@': { + count_1: { + kind: SyntaxKind.NumberKeyword, + type: 'NumberKeyword', + }, + count_2: { + kind: SyntaxKind.NumberKeyword, + type: 'NumberKeyword', }, }, }, diff --git a/packages/kbn-telemetry-tools/src/tools/__snapshots__/extract_collectors.test.ts.snap b/packages/kbn-telemetry-tools/src/tools/__snapshots__/extract_collectors.test.ts.snap deleted file mode 100644 index fe589be7993d0..0000000000000 --- a/packages/kbn-telemetry-tools/src/tools/__snapshots__/extract_collectors.test.ts.snap +++ /dev/null @@ -1,295 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`extractCollectors extracts collectors given rc file 1`] = ` -Array [ - Array [ - "src/fixtures/telemetry_collectors/externally_defined_collector.ts", - Object { - "collectorName": "from_variable_collector", - "fetch": Object { - "typeDescriptor": Object { - "locale": Object { - "kind": 146, - "type": "StringKeyword", - }, - }, - "typeName": "Usage", - }, - "schema": Object { - "value": Object { - "locale": Object { - "type": "keyword", - }, - }, - }, - }, - ], - Array [ - "src/fixtures/telemetry_collectors/externally_defined_collector.ts", - Object { - "collectorName": "from_fn_collector", - "fetch": Object { - "typeDescriptor": Object { - "locale": Object { - "kind": 146, - "type": "StringKeyword", - }, - }, - "typeName": "Usage", - }, - "schema": Object { - "value": Object { - "locale": Object { - "type": "keyword", - }, - }, - }, - }, - ], - Array [ - "src/fixtures/telemetry_collectors/imported_schema.ts", - Object { - "collectorName": "with_imported_schema", - "fetch": Object { - "typeDescriptor": Object { - "locale": Object { - "kind": 146, - "type": "StringKeyword", - }, - }, - "typeName": "Usage", - }, - "schema": Object { - "value": Object { - "locale": Object { - "type": "keyword", - }, - }, - }, - }, - ], - Array [ - "src/fixtures/telemetry_collectors/imported_usage_interface.ts", - Object { - "collectorName": "imported_usage_interface_collector", - "fetch": Object { - "typeDescriptor": Object { - "locale": Object { - "kind": 146, - "type": "StringKeyword", - }, - }, - "typeName": "Usage", - }, - "schema": Object { - "value": Object { - "locale": Object { - "type": "keyword", - }, - }, - }, - }, - ], - Array [ - "src/fixtures/telemetry_collectors/indexed_interface_with_not_matching_schema.ts", - Object { - "collectorName": "indexed_interface_with_not_matching_schema", - "fetch": Object { - "typeDescriptor": Object { - "@@INDEX@@": Object { - "count_1": Object { - "kind": 143, - "type": "NumberKeyword", - }, - "count_2": Object { - "kind": 143, - "type": "NumberKeyword", - }, - }, - }, - "typeName": "Usage", - }, - "schema": Object { - "value": Object { - "something": Object { - "count_1": Object { - "type": "long", - }, - }, - }, - }, - }, - ], - Array [ - "src/fixtures/telemetry_collectors/nested_collector.ts", - Object { - "collectorName": "my_nested_collector", - "fetch": Object { - "typeDescriptor": Object { - "locale": Object { - "kind": 146, - "type": "StringKeyword", - }, - }, - "typeName": "Usage", - }, - "schema": Object { - "value": Object { - "locale": Object { - "type": "keyword", - }, - }, - }, - }, - ], - Array [ - "src/fixtures/telemetry_collectors/schema_defined_with_spreads_collector.ts", - Object { - "collectorName": "schema_defined_with_spreads", - "fetch": Object { - "typeDescriptor": Object { - "flat": Object { - "kind": 146, - "type": "StringKeyword", - }, - "my_objects": Object { - "total": Object { - "kind": 143, - "type": "NumberKeyword", - }, - "type": Object { - "kind": 131, - "type": "BooleanKeyword", - }, - }, - "my_str": Object { - "kind": 146, - "type": "StringKeyword", - }, - }, - "typeName": "Usage", - }, - "schema": Object { - "value": Object { - "flat": Object { - "type": "keyword", - }, - "my_objects": Object { - "total": Object { - "type": "long", - }, - "type": Object { - "type": "boolean", - }, - }, - "my_str": Object { - "type": "text", - }, - }, - }, - }, - ], - Array [ - "src/fixtures/telemetry_collectors/working_collector.ts", - Object { - "collectorName": "my_working_collector", - "fetch": Object { - "typeDescriptor": Object { - "flat": Object { - "kind": 146, - "type": "StringKeyword", - }, - "my_array": Object { - "items": Object { - "total": Object { - "kind": 143, - "type": "NumberKeyword", - }, - "type": Object { - "kind": 131, - "type": "BooleanKeyword", - }, - }, - }, - "my_index_signature_prop": Object { - "@@INDEX@@": Object { - "kind": 143, - "type": "NumberKeyword", - }, - }, - "my_objects": Object { - "total": Object { - "kind": 143, - "type": "NumberKeyword", - }, - "type": Object { - "kind": 131, - "type": "BooleanKeyword", - }, - }, - "my_str": Object { - "kind": 146, - "type": "StringKeyword", - }, - "my_str_array": Object { - "items": Object { - "kind": 146, - "type": "StringKeyword", - }, - }, - }, - "typeName": "Usage", - }, - "schema": Object { - "value": Object { - "flat": Object { - "type": "keyword", - }, - "my_array": Object { - "items": Object { - "total": Object { - "type": "long", - }, - "type": Object { - "type": "boolean", - }, - }, - "type": "array", - }, - "my_index_signature_prop": Object { - "avg": Object { - "type": "float", - }, - "count": Object { - "type": "long", - }, - "max": Object { - "type": "long", - }, - "min": Object { - "type": "long", - }, - }, - "my_objects": Object { - "total": Object { - "type": "long", - }, - "type": Object { - "type": "boolean", - }, - }, - "my_str": Object { - "type": "text", - }, - "my_str_array": Object { - "items": Object { - "type": "keyword", - }, - "type": "array", - }, - }, - }, - }, - ], -] -`; diff --git a/packages/kbn-telemetry-tools/src/tools/check_collector__integrity.test.ts b/packages/kbn-telemetry-tools/src/tools/check_collector__integrity.test.ts index b6ea9d49cf6d0..b4e934746dc45 100644 --- a/packages/kbn-telemetry-tools/src/tools/check_collector__integrity.test.ts +++ b/packages/kbn-telemetry-tools/src/tools/check_collector__integrity.test.ts @@ -90,10 +90,10 @@ describe('checkCompatibleTypeDescriptor', () => { expect(incompatibles).toHaveLength(1); const { diff, message } = incompatibles[0]; // eslint-disable-next-line @typescript-eslint/naming-convention - expect(diff).toEqual({ '.@@INDEX@@.count_2.kind': 'number' }); + expect(diff).toEqual({ '@@INDEX@@.count_2.kind': 'number' }); expect(message).toHaveLength(1); expect(message).toEqual([ - 'incompatible Type key (Usage..@@INDEX@@.count_2): expected (undefined) got ("number").', + 'incompatible Type key (Usage.@@INDEX@@.count_2): expected (undefined) got ("number").', ]); }); diff --git a/packages/kbn-telemetry-tools/src/tools/extract_collectors.test.ts b/packages/kbn-telemetry-tools/src/tools/extract_collectors.test.ts index b03db75b219f6..9f1a1a2052791 100644 --- a/packages/kbn-telemetry-tools/src/tools/extract_collectors.test.ts +++ b/packages/kbn-telemetry-tools/src/tools/extract_collectors.test.ts @@ -21,6 +21,7 @@ import * as ts from 'typescript'; import * as path from 'path'; import { extractCollectors, getProgramPaths } from './extract_collectors'; import { parseTelemetryRC } from './config'; +import { allExtractedCollectors } from './__fixture__/all_extracted_collectors'; describe('extractCollectors', () => { it('extracts collectors given rc file', async () => { @@ -35,6 +36,6 @@ describe('extractCollectors', () => { const results = [...extractCollectors(programPaths, tsConfig)]; expect(results).toHaveLength(8); - expect(results).toMatchSnapshot(); + expect(results).toStrictEqual(allExtractedCollectors); }); }); diff --git a/src/dev/build/tasks/os_packages/package_scripts/post_install.sh b/src/dev/build/tasks/os_packages/package_scripts/post_install.sh index 728278dae746b..6eb111e066c83 100644 --- a/src/dev/build/tasks/os_packages/package_scripts/post_install.sh +++ b/src/dev/build/tasks/os_packages/package_scripts/post_install.sh @@ -17,6 +17,7 @@ set_chmod() { set_chown() { chown <%= user %>:<%= group %> <%= logDir %> + chown <%= user %>:<%= group %> <%= pidDir %> chown -R <%= user %>:<%= group %> <%= dataDir %> chown -R root:<%= group %> ${KBN_PATH_CONF} } diff --git a/src/dev/build/tasks/os_packages/run_fpm.ts b/src/dev/build/tasks/os_packages/run_fpm.ts index f16eaea1daa2f..7dff592eb9b83 100644 --- a/src/dev/build/tasks/os_packages/run_fpm.ts +++ b/src/dev/build/tasks/os_packages/run_fpm.ts @@ -112,6 +112,8 @@ export async function runFpm( '--template-value', `logDir=/var/log/kibana`, '--template-value', + `pidDir=/run/kibana`, + '--template-value', `envFile=/etc/default/kibana`, // config and data directories are copied to /usr/share and /var/lib // below, so exclude them from the main package source located in @@ -120,6 +122,8 @@ export async function runFpm( `usr/share/kibana/config`, '--exclude', `usr/share/kibana/data`, + '--exclude', + 'run/kibana/.gitempty', // flags specific to the package we are building, supplied by tasks below ...pkgSpecificFlags, diff --git a/src/dev/build/tasks/os_packages/service_templates/systemd/run/kibana/.gitempty b/src/dev/build/tasks/os_packages/service_templates/systemd/run/kibana/.gitempty new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_logic.test.ts new file mode 100644 index 0000000000000..d35bde20f4f1e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_logic.test.ts @@ -0,0 +1,163 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { resetContext } from 'kea'; + +import { mockHttpValues } from '../../../__mocks__'; +jest.mock('../../../shared/http', () => ({ + HttpLogic: { values: mockHttpValues }, +})); +const { http } = mockHttpValues; + +jest.mock('../../../shared/flash_messages', () => ({ + flashAPIErrors: jest.fn(), +})); +import { flashAPIErrors } from '../../../shared/flash_messages'; + +jest.mock('../engine', () => ({ + EngineLogic: { values: { engineName: 'some-engine' } }, +})); + +import { EngineOverviewLogic } from './'; + +describe('EngineOverviewLogic', () => { + const mockEngineMetrics = { + apiLogsUnavailable: true, + documentCount: 10, + startDate: '1970-01-30', + endDate: '1970-01-31', + operationsPerDay: [0, 0, 0, 0, 0, 0, 0], + queriesPerDay: [0, 0, 0, 0, 0, 25, 50], + totalClicks: 50, + totalQueries: 75, + }; + + const DEFAULT_VALUES = { + dataLoading: true, + apiLogsUnavailable: false, + documentCount: 0, + startDate: '', + endDate: '', + operationsPerDay: [], + queriesPerDay: [], + totalClicks: 0, + totalQueries: 0, + timeoutId: null, + }; + + const mount = () => { + resetContext({}); + EngineOverviewLogic.mount(); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('has expected default values', () => { + mount(); + expect(EngineOverviewLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('actions', () => { + describe('setPolledData', () => { + it('should set all received data as top-level values and set dataLoading to false', () => { + mount(); + EngineOverviewLogic.actions.setPolledData(mockEngineMetrics); + + expect(EngineOverviewLogic.values).toEqual({ + ...DEFAULT_VALUES, + ...mockEngineMetrics, + dataLoading: false, + }); + }); + }); + + describe('setTimeoutId', () => { + describe('timeoutId', () => { + it('should be set to the provided value', () => { + mount(); + EngineOverviewLogic.actions.setTimeoutId(123); + + expect(EngineOverviewLogic.values).toEqual({ + ...DEFAULT_VALUES, + timeoutId: 123, + }); + }); + }); + }); + + describe('pollForOverviewMetrics', () => { + it('fetches data and calls onPollingSuccess', async () => { + mount(); + jest.spyOn(EngineOverviewLogic.actions, 'onPollingSuccess'); + const promise = Promise.resolve(mockEngineMetrics); + http.get.mockReturnValueOnce(promise); + + EngineOverviewLogic.actions.pollForOverviewMetrics(); + await promise; + + expect(http.get).toHaveBeenCalledWith('/api/app_search/engines/some-engine/overview'); + expect(EngineOverviewLogic.actions.onPollingSuccess).toHaveBeenCalledWith( + mockEngineMetrics + ); + }); + + it('handles errors', async () => { + mount(); + const promise = Promise.reject('An error occurred'); + http.get.mockReturnValue(promise); + + try { + EngineOverviewLogic.actions.pollForOverviewMetrics(); + await promise; + } catch { + // Do nothing + } + expect(flashAPIErrors).toHaveBeenCalledWith('An error occurred'); + }); + }); + + describe('onPollingSuccess', () => { + it('starts a polling timeout and sets data', async () => { + mount(); + jest.useFakeTimers(); + jest.spyOn(EngineOverviewLogic.actions, 'setTimeoutId'); + jest.spyOn(EngineOverviewLogic.actions, 'setPolledData'); + + EngineOverviewLogic.actions.onPollingSuccess(mockEngineMetrics); + + expect(setTimeout).toHaveBeenCalledWith( + EngineOverviewLogic.actions.pollForOverviewMetrics, + 5000 + ); + expect(EngineOverviewLogic.actions.setTimeoutId).toHaveBeenCalledWith(expect.any(Number)); + expect(EngineOverviewLogic.actions.setPolledData).toHaveBeenCalledWith(mockEngineMetrics); + }); + }); + }); + + describe('unmount', () => { + let unmount: Function; + + beforeEach(() => { + jest.useFakeTimers(); + resetContext({}); + unmount = EngineOverviewLogic.mount(); + }); + + it('clears existing polling timeouts on unmount', () => { + EngineOverviewLogic.actions.setTimeoutId(123); + unmount(); + expect(clearTimeout).toHaveBeenCalled(); + }); + + it("does not clear timeout if one hasn't been set", () => { + unmount(); + expect(clearTimeout).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_logic.ts new file mode 100644 index 0000000000000..3fc7ce8083e03 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_logic.ts @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { kea, MakeLogicType } from 'kea'; + +import { flashAPIErrors } from '../../../shared/flash_messages'; +import { HttpLogic } from '../../../shared/http'; +import { EngineLogic } from '../engine'; + +const POLLING_DURATION = 5000; + +interface EngineOverviewApiData { + apiLogsUnavailable: boolean; + documentCount: number; + startDate: string; + endDate: string; + operationsPerDay: number[]; + queriesPerDay: number[]; + totalClicks: number; + totalQueries: number; +} +interface EngineOverviewValues extends EngineOverviewApiData { + dataLoading: boolean; + timeoutId: number | null; +} + +interface EngineOverviewActions { + setPolledData(engineMetrics: EngineOverviewApiData): EngineOverviewApiData; + setTimeoutId(timeoutId: number): { timeoutId: number }; + pollForOverviewMetrics(): void; + onPollingSuccess(engineMetrics: EngineOverviewApiData): EngineOverviewApiData; +} + +export const EngineOverviewLogic = kea>({ + path: ['enterprise_search', 'app_search', 'engine_overview_logic'], + actions: () => ({ + setPolledData: (engineMetrics) => engineMetrics, + setTimeoutId: (timeoutId) => ({ timeoutId }), + pollForOverviewMetrics: true, + onPollingSuccess: (engineMetrics) => engineMetrics, + }), + reducers: () => ({ + dataLoading: [ + true, + { + setPolledData: () => false, + }, + ], + apiLogsUnavailable: [ + false, + { + setPolledData: (_, { apiLogsUnavailable }) => apiLogsUnavailable, + }, + ], + startDate: [ + '', + { + setPolledData: (_, { startDate }) => startDate, + }, + ], + endDate: [ + '', + { + setPolledData: (_, { endDate }) => endDate, + }, + ], + queriesPerDay: [ + [], + { + setPolledData: (_, { queriesPerDay }) => queriesPerDay, + }, + ], + operationsPerDay: [ + [], + { + setPolledData: (_, { operationsPerDay }) => operationsPerDay, + }, + ], + totalQueries: [ + 0, + { + setPolledData: (_, { totalQueries }) => totalQueries, + }, + ], + totalClicks: [ + 0, + { + setPolledData: (_, { totalClicks }) => totalClicks, + }, + ], + documentCount: [ + 0, + { + setPolledData: (_, { documentCount }) => documentCount, + }, + ], + timeoutId: [ + null, + { + setTimeoutId: (_, { timeoutId }) => timeoutId, + }, + ], + }), + listeners: ({ actions }) => ({ + pollForOverviewMetrics: async () => { + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + + try { + const response = await http.get(`/api/app_search/engines/${engineName}/overview`); + actions.onPollingSuccess(response); + } catch (e) { + flashAPIErrors(e); + } + }, + onPollingSuccess: (engineMetrics) => { + const timeoutId = window.setTimeout(actions.pollForOverviewMetrics, POLLING_DURATION); + actions.setTimeoutId(timeoutId); + actions.setPolledData(engineMetrics); + }, + }), + events: ({ values }) => ({ + beforeUnmount() { + if (values.timeoutId !== null) clearTimeout(values.timeoutId); + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/index.ts new file mode 100644 index 0000000000000..fcd92ba6a338c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { EngineOverviewLogic } from './engine_overview_logic'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.test.tsx index e3e9872f892a4..9eaa2ba4c4d6f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.test.tsx @@ -105,6 +105,32 @@ describe('SideNavLink', () => { expect(wrapper.find('.enterpriseSearchNavLinks__subNav')).toHaveLength(1); expect(wrapper.find('[data-test-subj="subNav"]')).toHaveLength(1); }); + + describe('shouldShowActiveForSubroutes', () => { + it("won't set an active class when route is a subroute of 'to'", () => { + (useLocation as jest.Mock).mockImplementationOnce(() => ({ pathname: '/documents/1234' })); + + const wrapper = shallow( + + Link + + ); + + expect(wrapper.find('.enterpriseSearchNavLinks__item--isActive')).toHaveLength(0); + }); + + it('sets an active class if the current path is a subRoute of "to", and shouldShowActiveForSubroutes is true', () => { + (useLocation as jest.Mock).mockImplementationOnce(() => ({ pathname: '/documents/1234' })); + + const wrapper = shallow( + + Link + + ); + + expect(wrapper.find('.enterpriseSearchNavLinks__item--isActive')).toHaveLength(1); + }); + }); }); describe('SideNavItem', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.tsx index 6c4e1d084c16d..c75a48d5af41d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.tsx @@ -63,6 +63,7 @@ export const SideNav: React.FC = ({ product, children }) => { interface SideNavLinkProps { to: string; + shouldShowActiveForSubroutes?: boolean; isExternal?: boolean; className?: string; isRoot?: boolean; @@ -70,8 +71,9 @@ interface SideNavLinkProps { } export const SideNavLink: React.FC = ({ - isExternal, to, + shouldShowActiveForSubroutes = false, + isExternal, children, className, isRoot, @@ -82,7 +84,10 @@ export const SideNavLink: React.FC = ({ const { pathname } = useLocation(); const currentPath = stripTrailingSlash(pathname); - const isActive = currentPath === to || (isRoot && currentPath === ''); + const isActive = + currentPath === to || + (shouldShowActiveForSubroutes && currentPath.startsWith(to)) || + (isRoot && currentPath === ''); const classes = classNames('enterpriseSearchNavLinks__item', className, { 'enterpriseSearchNavLinks__item--isActive': !isExternal && isActive, // eslint-disable-line @typescript-eslint/naming-convention diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts index b7009c1b76fbc..ed6847a029100 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts @@ -116,7 +116,6 @@ describe('engine routes', () => { mockRouter = new MockRouter({ method: 'get', path: '/api/app_search/engines/{name}', - payload: 'params', }); registerEnginesRoutes({ @@ -133,4 +132,29 @@ describe('engine routes', () => { }); }); }); + + describe('GET /api/app_search/engines/{name}/overview', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/app_search/engines/{name}/overview', + }); + + registerEnginesRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request to enterprise search', () => { + mockRouter.callRoute({ params: { name: 'some-engine' } }); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/as/engines/some-engine/overview_metrics', + }); + }); + }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts index 2c4e235556ae3..f9169d8795f4b 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts @@ -60,4 +60,19 @@ export function registerEnginesRoutes({ })(context, request, response); } ); + router.get( + { + path: '/api/app_search/engines/{name}/overview', + validate: { + params: schema.object({ + name: schema.string(), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/as/engines/${request.params.name}/overview_metrics`, + })(context, request, response); + } + ); } diff --git a/x-pack/plugins/features/common/sub_feature.ts b/x-pack/plugins/features/common/sub_feature.ts index 0651bad883ea5..f791db6154731 100644 --- a/x-pack/plugins/features/common/sub_feature.ts +++ b/x-pack/plugins/features/common/sub_feature.ts @@ -5,6 +5,7 @@ */ import { RecursiveReadonly } from '@kbn/utility-types'; +import { LicenseType } from '../../licensing/common/types'; import { FeatureKibanaPrivileges } from './feature_kibana_privileges'; /** @@ -68,6 +69,13 @@ export interface SubFeaturePrivilegeConfig * `read` is also included in `all` automatically. */ includeIn: 'all' | 'read' | 'none'; + + /** + * The minimum supported license level for this sub-feature privilege. + * If no license level is supplied, then this privilege will be available for all licences + * that are valid for the overall feature. + */ + minimumLicense?: LicenseType; } export class SubFeature { 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 f616daebf662a..e18acbfea8f48 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,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`buildOSSFeatures returns the advancedSettings feature augmented with appropriate sub feature privileges 1`] = ` +exports[`buildOSSFeatures with a basic license returns the advancedSettings feature augmented with appropriate sub feature privileges 1`] = ` Array [ Object { "privilege": Object { @@ -51,7 +51,7 @@ Array [ ] `; -exports[`buildOSSFeatures returns the dashboard feature augmented with appropriate sub feature privileges 1`] = ` +exports[`buildOSSFeatures with a basic license returns the dashboard feature augmented with appropriate sub feature privileges 1`] = ` Array [ Object { "privilege": Object { @@ -128,7 +128,7 @@ Array [ ] `; -exports[`buildOSSFeatures returns the dev_tools feature augmented with appropriate sub feature privileges 1`] = ` +exports[`buildOSSFeatures with a basic license returns the dev_tools feature augmented with appropriate sub feature privileges 1`] = ` Array [ Object { "privilege": Object { @@ -182,7 +182,7 @@ Array [ ] `; -exports[`buildOSSFeatures returns the discover feature augmented with appropriate sub feature privileges 1`] = ` +exports[`buildOSSFeatures with a basic license returns the discover feature augmented with appropriate sub feature privileges 1`] = ` Array [ Object { "privilege": Object { @@ -243,7 +243,7 @@ Array [ ] `; -exports[`buildOSSFeatures returns the indexPatterns feature augmented with appropriate sub feature privileges 1`] = ` +exports[`buildOSSFeatures with a basic license returns the indexPatterns feature augmented with appropriate sub feature privileges 1`] = ` Array [ Object { "privilege": Object { @@ -296,7 +296,7 @@ Array [ ] `; -exports[`buildOSSFeatures returns the savedObjectsManagement feature augmented with appropriate sub feature privileges 1`] = ` +exports[`buildOSSFeatures with a basic license returns the savedObjectsManagement feature augmented with appropriate sub feature privileges 1`] = ` Array [ Object { "privilege": Object { @@ -363,7 +363,7 @@ Array [ ] `; -exports[`buildOSSFeatures returns the timelion feature augmented with appropriate sub feature privileges 1`] = ` +exports[`buildOSSFeatures with a basic license returns the timelion feature augmented with appropriate sub feature privileges 1`] = ` Array [ Object { "privilege": Object { @@ -411,7 +411,489 @@ Array [ ] `; -exports[`buildOSSFeatures returns the visualize feature augmented with appropriate sub feature privileges 1`] = ` +exports[`buildOSSFeatures with a basic license returns the visualize feature augmented with appropriate sub feature privileges 1`] = ` +Array [ + Object { + "privilege": Object { + "alerting": Object { + "all": Array [], + "read": Array [], + }, + "api": Array [], + "app": Array [ + "visualize", + "lens", + "kibana", + ], + "catalogue": Array [ + "visualize", + ], + "management": Object {}, + "savedObject": Object { + "all": Array [ + "visualization", + "query", + "lens", + "url", + ], + "read": Array [ + "index-pattern", + "search", + "tag", + ], + }, + "ui": Array [ + "show", + "delete", + "save", + "saveQuery", + "createShortUrl", + ], + }, + "privilegeId": "all", + }, + Object { + "privilege": Object { + "app": Array [ + "visualize", + "lens", + "kibana", + ], + "catalogue": Array [ + "visualize", + ], + "savedObject": Object { + "all": Array [], + "read": Array [ + "index-pattern", + "search", + "visualization", + "query", + "lens", + "tag", + ], + }, + "ui": Array [ + "show", + ], + }, + "privilegeId": "read", + }, +] +`; + +exports[`buildOSSFeatures with a enterprise license returns the advancedSettings feature augmented with appropriate sub feature privileges 1`] = ` +Array [ + Object { + "privilege": Object { + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "advanced_settings", + ], + "management": Object { + "kibana": Array [ + "settings", + ], + }, + "savedObject": Object { + "all": Array [ + "config", + ], + "read": Array [], + }, + "ui": Array [ + "save", + ], + }, + "privilegeId": "all", + }, + Object { + "privilege": Object { + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "advanced_settings", + ], + "management": Object { + "kibana": Array [ + "settings", + ], + }, + "savedObject": Object { + "all": Array [], + "read": Array [], + }, + "ui": Array [], + }, + "privilegeId": "read", + }, +] +`; + +exports[`buildOSSFeatures with a enterprise license returns the dashboard feature augmented with appropriate sub feature privileges 1`] = ` +Array [ + Object { + "privilege": Object { + "alerting": Object { + "all": Array [], + "read": Array [], + }, + "api": Array [], + "app": Array [ + "dashboards", + "kibana", + ], + "catalogue": Array [ + "dashboard", + ], + "management": Object {}, + "savedObject": Object { + "all": Array [ + "dashboard", + "query", + "url", + ], + "read": Array [ + "index-pattern", + "search", + "visualization", + "timelion-sheet", + "canvas-workpad", + "lens", + "map", + "tag", + ], + }, + "ui": Array [ + "createNew", + "show", + "showWriteControls", + "saveQuery", + "createShortUrl", + ], + }, + "privilegeId": "all", + }, + Object { + "privilege": Object { + "app": Array [ + "dashboards", + "kibana", + ], + "catalogue": Array [ + "dashboard", + ], + "savedObject": Object { + "all": Array [], + "read": Array [ + "index-pattern", + "search", + "visualization", + "timelion-sheet", + "canvas-workpad", + "lens", + "map", + "dashboard", + "query", + "tag", + ], + }, + "ui": Array [ + "show", + ], + }, + "privilegeId": "read", + }, +] +`; + +exports[`buildOSSFeatures with a enterprise license returns the dev_tools feature augmented with appropriate sub feature privileges 1`] = ` +Array [ + Object { + "privilege": Object { + "api": Array [ + "console", + ], + "app": Array [ + "dev_tools", + "kibana", + ], + "catalogue": Array [ + "console", + "searchprofiler", + "grokdebugger", + ], + "savedObject": Object { + "all": Array [], + "read": Array [], + }, + "ui": Array [ + "show", + "save", + ], + }, + "privilegeId": "all", + }, + Object { + "privilege": Object { + "api": Array [ + "console", + ], + "app": Array [ + "dev_tools", + "kibana", + ], + "catalogue": Array [ + "console", + "searchprofiler", + "grokdebugger", + ], + "savedObject": Object { + "all": Array [], + "read": Array [], + }, + "ui": Array [ + "show", + ], + }, + "privilegeId": "read", + }, +] +`; + +exports[`buildOSSFeatures with a enterprise license returns the discover feature augmented with appropriate sub feature privileges 1`] = ` +Array [ + Object { + "privilege": Object { + "alerting": Object { + "all": Array [], + "read": Array [], + }, + "api": Array [], + "app": Array [ + "discover", + "kibana", + ], + "catalogue": Array [ + "discover", + ], + "management": Object {}, + "savedObject": Object { + "all": Array [ + "search", + "query", + "index-pattern", + "url", + ], + "read": Array [], + }, + "ui": Array [ + "show", + "save", + "saveQuery", + "createShortUrl", + ], + }, + "privilegeId": "all", + }, + Object { + "privilege": Object { + "app": Array [ + "discover", + "kibana", + ], + "catalogue": Array [ + "discover", + ], + "savedObject": Object { + "all": Array [], + "read": Array [ + "index-pattern", + "search", + "query", + ], + }, + "ui": Array [ + "show", + ], + }, + "privilegeId": "read", + }, +] +`; + +exports[`buildOSSFeatures with a enterprise license returns the indexPatterns feature augmented with appropriate sub feature privileges 1`] = ` +Array [ + Object { + "privilege": Object { + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "indexPatterns", + ], + "management": Object { + "kibana": Array [ + "indexPatterns", + ], + }, + "savedObject": Object { + "all": Array [ + "index-pattern", + ], + "read": Array [], + }, + "ui": Array [ + "save", + ], + }, + "privilegeId": "all", + }, + Object { + "privilege": Object { + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "indexPatterns", + ], + "management": Object { + "kibana": Array [ + "indexPatterns", + ], + }, + "savedObject": Object { + "all": Array [], + "read": Array [ + "index-pattern", + ], + }, + "ui": Array [], + }, + "privilegeId": "read", + }, +] +`; + +exports[`buildOSSFeatures with a enterprise license returns the savedObjectsManagement feature augmented with appropriate sub feature privileges 1`] = ` +Array [ + Object { + "privilege": Object { + "api": Array [ + "copySavedObjectsToSpaces", + ], + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "saved_objects", + ], + "management": Object { + "kibana": Array [ + "objects", + ], + }, + "savedObject": Object { + "all": Array [ + "foo", + "bar", + ], + "read": Array [], + }, + "ui": Array [ + "read", + "edit", + "delete", + "copyIntoSpace", + "shareIntoSpace", + ], + }, + "privilegeId": "all", + }, + Object { + "privilege": Object { + "api": Array [ + "copySavedObjectsToSpaces", + ], + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "saved_objects", + ], + "management": Object { + "kibana": Array [ + "objects", + ], + }, + "savedObject": Object { + "all": Array [], + "read": Array [ + "foo", + "bar", + ], + }, + "ui": Array [ + "read", + ], + }, + "privilegeId": "read", + }, +] +`; + +exports[`buildOSSFeatures with a enterprise license returns the timelion feature augmented with appropriate sub feature privileges 1`] = ` +Array [ + Object { + "privilege": Object { + "app": Array [ + "timelion", + "kibana", + ], + "catalogue": Array [ + "timelion", + ], + "savedObject": Object { + "all": Array [ + "timelion-sheet", + ], + "read": Array [ + "index-pattern", + ], + }, + "ui": Array [ + "save", + ], + }, + "privilegeId": "all", + }, + Object { + "privilege": Object { + "app": Array [ + "timelion", + "kibana", + ], + "catalogue": Array [ + "timelion", + ], + "savedObject": Object { + "all": Array [], + "read": Array [ + "index-pattern", + "timelion-sheet", + ], + }, + "ui": Array [], + }, + "privilegeId": "read", + }, +] +`; + +exports[`buildOSSFeatures with a enterprise license returns the visualize feature augmented with appropriate sub feature privileges 1`] = ` Array [ Object { "privilege": Object { diff --git a/x-pack/plugins/features/server/feature_registry.test.ts b/x-pack/plugins/features/server/feature_registry.test.ts index fda72e4536939..6deb7cd968490 100644 --- a/x-pack/plugins/features/server/feature_registry.test.ts +++ b/x-pack/plugins/features/server/feature_registry.test.ts @@ -6,6 +6,7 @@ import { FeatureRegistry } from './feature_registry'; import { ElasticsearchFeatureConfig, KibanaFeatureConfig } from '../common'; +import { licensingMock } from '../../licensing/server/mocks'; describe('FeatureRegistry', () => { describe('Kibana Features', () => { @@ -1280,6 +1281,123 @@ describe('FeatureRegistry', () => { ); }); + it('allows independent sub-feature privileges to register a minimumLicense', () => { + const feature1: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + category: { id: 'foo', label: 'foo' }, + privileges: { + all: { + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + }, + subFeatures: [ + { + name: 'foo', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'foo', + name: 'foo', + minimumLicense: 'platinum', + includeIn: 'all', + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + ], + }, + ], + }, + ], + }; + + const featureRegistry = new FeatureRegistry(); + featureRegistry.registerKibanaFeature(feature1); + }); + + it('prevents mutually exclusive sub-feature privileges from registering a minimumLicense', () => { + const feature1: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + category: { id: 'foo', label: 'foo' }, + privileges: { + all: { + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + }, + subFeatures: [ + { + name: 'foo', + privilegeGroups: [ + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'foo', + name: 'foo', + minimumLicense: 'platinum', + includeIn: 'all', + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + { + id: 'bar', + name: 'Bar', + minimumLicense: 'platinum', + includeIn: 'all', + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + ], + }, + ], + }, + ], + }; + + const featureRegistry = new FeatureRegistry(); + expect(() => { + featureRegistry.registerKibanaFeature(feature1); + }).toThrowErrorMatchingInlineSnapshot( + `"child \\"subFeatures\\" fails because [\\"subFeatures\\" at position 0 fails because [child \\"privilegeGroups\\" fails because [\\"privilegeGroups\\" at position 0 fails because [child \\"privileges\\" fails because [\\"privileges\\" at position 0 fails because [child \\"minimumLicense\\" fails because [\\"minimumLicense\\" is not allowed]]]]]]]"` + ); + }); + it('cannot register feature after getAll has been called', () => { const feature1: KibanaFeatureConfig = { id: 'test-feature', @@ -1305,6 +1423,89 @@ describe('FeatureRegistry', () => { `"Features are locked, can't register new features. Attempt to register test-feature-2 failed."` ); }); + describe('#getAllKibanaFeatures', () => { + const features: KibanaFeatureConfig[] = [ + { + id: 'gold-feature', + name: 'Test Feature', + app: [], + category: { id: 'foo', label: 'foo' }, + minimumLicense: 'gold', + privileges: null, + }, + { + id: 'unlicensed-feature', + name: 'Test Feature', + app: [], + category: { id: 'foo', label: 'foo' }, + privileges: null, + }, + { + id: 'with-sub-feature', + name: 'Test Feature', + app: [], + category: { id: 'foo', label: 'foo' }, + privileges: { + all: { savedObject: { all: [], read: [] }, ui: [] }, + read: { savedObject: { all: [], read: [] }, ui: [] }, + }, + minimumLicense: 'platinum', + subFeatures: [ + { + name: 'licensed-sub-feature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-feature', + includeIn: 'all', + minimumLicense: 'enterprise', + name: 'sub feature', + savedObject: { all: [], read: [] }, + ui: [], + }, + ], + }, + ], + }, + ], + }, + ]; + + const registry = new FeatureRegistry(); + features.forEach((f) => registry.registerKibanaFeature(f)); + + it('returns all features and sub-feature privileges by default', () => { + const result = registry.getAllKibanaFeatures(); + expect(result).toHaveLength(3); + const [, , withSubFeature] = result; + expect(withSubFeature.subFeatures).toHaveLength(1); + expect(withSubFeature.subFeatures[0].privilegeGroups).toHaveLength(1); + expect(withSubFeature.subFeatures[0].privilegeGroups[0].privileges).toHaveLength(1); + }); + + it('returns features which are satisfied by the current license', () => { + const license = licensingMock.createLicense({ license: { type: 'gold' } }); + const result = registry.getAllKibanaFeatures(license); + expect(result).toHaveLength(2); + const ids = result.map((f) => f.id); + expect(ids).toEqual(['gold-feature', 'unlicensed-feature']); + }); + + it('filters out sub-feature privileges which do not match the current license', () => { + const license = licensingMock.createLicense({ license: { type: 'platinum' } }); + const result = registry.getAllKibanaFeatures(license); + expect(result).toHaveLength(3); + const ids = result.map((f) => f.id); + expect(ids).toEqual(['gold-feature', 'unlicensed-feature', 'with-sub-feature']); + + const [, , withSubFeature] = result; + expect(withSubFeature.subFeatures).toHaveLength(1); + expect(withSubFeature.subFeatures[0].privilegeGroups).toHaveLength(1); + expect(withSubFeature.subFeatures[0].privilegeGroups[0].privileges).toHaveLength(0); + }); + }); }); describe('Elasticsearch Features', () => { diff --git a/x-pack/plugins/features/server/feature_registry.ts b/x-pack/plugins/features/server/feature_registry.ts index e9e556ba22fd2..cdceb5a2d1c77 100644 --- a/x-pack/plugins/features/server/feature_registry.ts +++ b/x-pack/plugins/features/server/feature_registry.ts @@ -5,6 +5,7 @@ */ import { cloneDeep, uniq } from 'lodash'; +import { ILicense } from '../../licensing/server'; import { KibanaFeatureConfig, KibanaFeature, @@ -55,11 +56,30 @@ export class FeatureRegistry { this.esFeatures[feature.id] = featureCopy; } - public getAllKibanaFeatures(): KibanaFeature[] { + public getAllKibanaFeatures(license?: ILicense, ignoreLicense = false): KibanaFeature[] { this.locked = true; - return Object.values(this.kibanaFeatures).map( - (featureConfig) => new KibanaFeature(featureConfig) - ); + let features = Object.values(this.kibanaFeatures); + + const performLicenseCheck = license && !ignoreLicense; + + if (performLicenseCheck) { + features = features.filter((feature) => { + const filter = !feature.minimumLicense || license!.hasAtLeast(feature.minimumLicense); + if (!filter) return false; + + feature.subFeatures?.forEach((subFeature) => { + subFeature.privilegeGroups.forEach((group) => { + group.privileges = group.privileges.filter( + (privilege) => + !privilege.minimumLicense || license!.hasAtLeast(privilege.minimumLicense) + ); + }); + }); + + return true; + }); + } + return features.map((featureConfig) => new KibanaFeature(featureConfig)); } public getAllElasticsearchFeatures(): ElasticsearchFeature[] { diff --git a/x-pack/plugins/features/server/feature_schema.ts b/x-pack/plugins/features/server/feature_schema.ts index 78ffcdb087360..3d8b649802168 100644 --- a/x-pack/plugins/features/server/feature_schema.ts +++ b/x-pack/plugins/features/server/feature_schema.ts @@ -21,6 +21,11 @@ const managementSectionIdRegex = /^[a-zA-Z0-9_-]+$/; const reservedFeaturePrrivilegePartRegex = /^(?!reserved_)[a-zA-Z0-9_-]+$/; export const uiCapabilitiesRegex = /^[a-zA-Z0-9:_-]+$/; +const validLicenses = ['basic', 'standard', 'gold', 'platinum', 'enterprise', 'trial']; +// sub-feature privileges are only available with a `gold` license or better, so restricting sub-feature privileges +// for `gold` or below doesn't make a whole lot of sense. +const validSubFeaturePrivilegeLicenses = ['platinum', 'enterprise', 'trial']; + const managementSchema = Joi.object().pattern( managementSectionIdRegex, Joi.array().items(Joi.string().regex(uiCapabilitiesRegex)) @@ -53,10 +58,11 @@ const kibanaPrivilegeSchema = Joi.object({ ui: Joi.array().items(Joi.string().regex(uiCapabilitiesRegex)).required(), }); -const kibanaSubFeaturePrivilegeSchema = Joi.object({ +const kibanaIndependentSubFeaturePrivilegeSchema = Joi.object({ id: Joi.string().regex(subFeaturePrivilegePartRegex).required(), name: Joi.string().required(), includeIn: Joi.string().allow('all', 'read', 'none').required(), + minimumLicense: Joi.string().valid(...validSubFeaturePrivilegeLicenses), management: managementSchema, catalogue: catalogueSchema, alerting: Joi.object({ @@ -72,12 +78,22 @@ const kibanaSubFeaturePrivilegeSchema = Joi.object({ ui: Joi.array().items(Joi.string().regex(uiCapabilitiesRegex)).required(), }); +const kibanaMutuallyExclusiveSubFeaturePrivilegeSchema = kibanaIndependentSubFeaturePrivilegeSchema.keys( + { + minimumLicense: Joi.forbidden(), + } +); + const kibanaSubFeatureSchema = Joi.object({ name: Joi.string().required(), privilegeGroups: Joi.array().items( Joi.object({ groupType: Joi.string().valid('mutually_exclusive', 'independent').required(), - privileges: Joi.array().items(kibanaSubFeaturePrivilegeSchema).min(1), + privileges: Joi.when('groupType', { + is: 'mutually_exclusive', + then: Joi.array().items(kibanaMutuallyExclusiveSubFeaturePrivilegeSchema).min(1), + otherwise: Joi.array().items(kibanaIndependentSubFeaturePrivilegeSchema).min(1), + }), }) ), }); @@ -91,14 +107,7 @@ const kibanaFeatureSchema = Joi.object({ category: appCategorySchema, order: Joi.number(), excludeFromBasePrivileges: Joi.boolean(), - minimumLicense: Joi.string().valid( - 'basic', - 'standard', - 'gold', - 'platinum', - 'enterprise', - 'trial' - ), + minimumLicense: Joi.string().valid(...validLicenses), app: Joi.array().items(Joi.string()).required(), management: managementSchema, catalogue: catalogueSchema, diff --git a/x-pack/plugins/features/server/oss_features.test.ts b/x-pack/plugins/features/server/oss_features.test.ts index 961656aba8bfd..a22e95105ba05 100644 --- a/x-pack/plugins/features/server/oss_features.test.ts +++ b/x-pack/plugins/features/server/oss_features.test.ts @@ -7,6 +7,7 @@ import { buildOSSFeatures } from './oss_features'; import { featurePrivilegeIterator } from '../../security/server/authorization'; import { KibanaFeature } from '.'; +import { LicenseType } from '../../licensing/server'; describe('buildOSSFeatures', () => { it('returns features including timelion', () => { @@ -46,14 +47,22 @@ Array [ const features = buildOSSFeatures({ savedObjectTypes: ['foo', 'bar'], includeTimelion: true }); features.forEach((featureConfig) => { - it(`returns the ${featureConfig.id} feature augmented with appropriate sub feature privileges`, () => { - const privileges = []; - for (const featurePrivilege of featurePrivilegeIterator(new KibanaFeature(featureConfig), { - augmentWithSubFeaturePrivileges: true, - })) { - privileges.push(featurePrivilege); - } - expect(privileges).toMatchSnapshot(); + (['enterprise', 'basic'] as LicenseType[]).forEach((licenseType) => { + describe(`with a ${licenseType} license`, () => { + it(`returns the ${featureConfig.id} feature augmented with appropriate sub feature privileges`, () => { + const privileges = []; + for (const featurePrivilege of featurePrivilegeIterator( + new KibanaFeature(featureConfig), + { + augmentWithSubFeaturePrivileges: true, + licenseType, + } + )) { + privileges.push(featurePrivilege); + } + expect(privileges).toMatchSnapshot(); + }); + }); }); }); }); diff --git a/x-pack/plugins/features/server/routes/index.test.ts b/x-pack/plugins/features/server/routes/index.test.ts index 7080f18906146..a3a038533777c 100644 --- a/x-pack/plugins/features/server/routes/index.test.ts +++ b/x-pack/plugins/features/server/routes/index.test.ts @@ -11,15 +11,78 @@ import { httpServerMock, httpServiceMock, coreMock } from '../../../../../src/co import { LicenseType } from '../../../licensing/server/'; import { licensingMock } from '../../../licensing/server/mocks'; import { RequestHandler } from '../../../../../src/core/server'; -import { KibanaFeatureConfig } from '../../common'; +import { FeatureKibanaPrivileges, KibanaFeatureConfig, SubFeatureConfig } from '../../common'; -function createContextMock(licenseType: LicenseType = 'gold') { +function createContextMock(licenseType: LicenseType = 'platinum') { return { core: coreMock.createRequestHandlerContext(), licensing: licensingMock.createRequestHandlerContext({ license: { type: licenseType } }), }; } +function createPrivilege(partial: Partial = {}): FeatureKibanaPrivileges { + return { + savedObject: { + all: [], + read: [], + }, + ui: [], + ...partial, + }; +} + +function getExpectedSubFeatures(licenseType: LicenseType = 'platinum'): SubFeatureConfig[] { + return [ + { + name: 'basicFeature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'basicSub1', + name: 'basic sub 1', + includeIn: 'all', + ...createPrivilege(), + }, + ], + }, + ], + }, + { + name: 'platinumFeature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: + licenseType !== 'basic' + ? [ + { + id: 'platinumFeatureSub1', + name: 'platinum sub 1', + includeIn: 'all', + minimumLicense: 'platinum', + ...createPrivilege(), + }, + ] + : [], + }, + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'platinumFeatureMutExSub1', + name: 'platinum sub 1', + includeIn: 'all', + ...createPrivilege(), + }, + ], + }, + ], + }, + ]; +} + describe('GET /api/features', () => { let routeHandler: RequestHandler; beforeEach(() => { @@ -29,7 +92,11 @@ describe('GET /api/features', () => { name: 'Feature 1', app: [], category: { id: 'foo', label: 'foo' }, - privileges: null, + privileges: { + all: createPrivilege(), + read: createPrivilege(), + }, + subFeatures: getExpectedSubFeatures(), }); featureRegistry.registerKibanaFeature({ @@ -76,7 +143,12 @@ describe('GET /api/features', () => { const [call] = mockResponse.ok.mock.calls; const body = call[0]!.body as KibanaFeatureConfig[]; - const features = body.map((feature) => ({ id: feature.id, order: feature.order })); + const features = body.map((feature) => ({ + id: feature.id, + order: feature.order, + subFeatures: feature.subFeatures, + })); + expect(features).toEqual([ { id: 'feature_3', @@ -89,6 +161,7 @@ describe('GET /api/features', () => { { id: 'feature_1', order: undefined, + subFeatures: getExpectedSubFeatures(), }, { id: 'licensed_feature', @@ -105,7 +178,11 @@ describe('GET /api/features', () => { const [call] = mockResponse.ok.mock.calls; const body = call[0]!.body as KibanaFeatureConfig[]; - const features = body.map((feature) => ({ id: feature.id, order: feature.order })); + const features = body.map((feature) => ({ + id: feature.id, + order: feature.order, + subFeatures: feature.subFeatures, + })); expect(features).toEqual([ { @@ -119,6 +196,7 @@ describe('GET /api/features', () => { { id: 'feature_1', order: undefined, + subFeatures: getExpectedSubFeatures('basic'), }, ]); }); @@ -135,7 +213,11 @@ describe('GET /api/features', () => { const [call] = mockResponse.ok.mock.calls; const body = call[0]!.body as KibanaFeatureConfig[]; - const features = body.map((feature) => ({ id: feature.id, order: feature.order })); + const features = body.map((feature) => ({ + id: feature.id, + order: feature.order, + subFeatures: feature.subFeatures, + })); expect(features).toEqual([ { @@ -149,6 +231,7 @@ describe('GET /api/features', () => { { id: 'feature_1', order: undefined, + subFeatures: getExpectedSubFeatures('basic'), }, ]); }); @@ -165,7 +248,11 @@ describe('GET /api/features', () => { const [call] = mockResponse.ok.mock.calls; const body = call[0]!.body as KibanaFeatureConfig[]; - const features = body.map((feature) => ({ id: feature.id, order: feature.order })); + const features = body.map((feature) => ({ + id: feature.id, + order: feature.order, + subFeatures: feature.subFeatures, + })); expect(features).toEqual([ { @@ -179,6 +266,7 @@ describe('GET /api/features', () => { { id: 'feature_1', order: undefined, + subFeatures: getExpectedSubFeatures(), }, { id: 'licensed_feature', diff --git a/x-pack/plugins/features/server/routes/index.ts b/x-pack/plugins/features/server/routes/index.ts index 1b0cd20775352..b2bfa8b0296b7 100644 --- a/x-pack/plugins/features/server/routes/index.ts +++ b/x-pack/plugins/features/server/routes/index.ts @@ -26,17 +26,15 @@ export function defineRoutes({ router, featureRegistry }: RouteDefinitionParams) }, }, (context, request, response) => { - const allFeatures = featureRegistry.getAllKibanaFeatures(); + const currentLicense = context.licensing!.license; + + const allFeatures = featureRegistry.getAllKibanaFeatures( + currentLicense, + request.query.ignoreValidLicenses + ); return response.ok({ body: allFeatures - .filter( - (feature) => - request.query.ignoreValidLicenses || - !feature.minimumLicense || - (context.licensing!.license && - context.licensing!.license.hasAtLeast(feature.minimumLicense)) - ) .sort( (f1, f2) => (f1.order ?? Number.MAX_SAFE_INTEGER) - (f2.order ?? Number.MAX_SAFE_INTEGER) diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/constants.ts b/x-pack/plugins/index_management/__jest__/client_integration/helpers/constants.ts new file mode 100644 index 0000000000000..14ef9760d7a05 --- /dev/null +++ b/x-pack/plugins/index_management/__jest__/client_integration/helpers/constants.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const BRANCH = '8.x'; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/index.ts b/x-pack/plugins/index_management/__jest__/client_integration/helpers/index.ts index d2d7eb0165d30..65af41033561e 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/helpers/index.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/helpers/index.ts @@ -11,3 +11,5 @@ export { nextTick, getRandomString, findTestSubject, TestBed } from '@kbn/test/j export { setupEnvironment, WithAppDependencies, services } from './setup_environment'; export { TestSubjects } from './test_subjects'; + +export { BRANCH } from './constants'; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx index 37d489b6afe72..6ba2454025beb 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import * as fixtures from '../../../test/fixtures'; -import { setupEnvironment } from '../helpers'; +import { setupEnvironment, BRANCH } from '../helpers'; import { TEMPLATE_NAME, SETTINGS, ALIASES, MAPPINGS as DEFAULT_MAPPING } from './constants'; import { setup } from './template_edit.helpers'; @@ -224,4 +224,69 @@ describe('', () => { }); }); }); + + // @ts-expect-error + if (BRANCH === '7.x') { + describe('legacy index templates', () => { + const legacyTemplateToEdit = fixtures.getTemplate({ + name: 'legacy_index_template', + indexPatterns: ['indexPattern1'], + isLegacy: true, + template: { + mappings: { + my_mapping_type: {}, + }, + }, + }); + + beforeAll(() => { + httpRequestsMockHelpers.setLoadTemplateResponse(legacyTemplateToEdit); + }); + + beforeEach(async () => { + await act(async () => { + testBed = await setup(); + }); + + testBed.component.update(); + }); + + it('persists mappings type', async () => { + const { actions } = testBed; + // Logistics + await actions.completeStepOne(); + // Note: "step 2" (component templates) doesn't exist for legacy templates + // Index settings + await actions.completeStepThree(); + // Mappings + await actions.completeStepFour(); + // Aliases + await actions.completeStepFive(); + + // Submit the form + await act(async () => { + actions.clickNextButton(); + }); + + const latestRequest = server.requests[server.requests.length - 1]; + + const { version, template, name, indexPatterns, _kbnMeta, order } = legacyTemplateToEdit; + + const expected = { + name, + indexPatterns, + version, + order, + template: { + aliases: undefined, + mappings: template!.mappings, + settings: undefined, + }, + _kbnMeta, + }; + + expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected); + }); + }); + } }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx index b023a9a5a3ec5..b9d9d6306b9ae 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx @@ -32,9 +32,13 @@ export const FilterPopover = ({ const [isPopoverOpen, setIsPopoverOpen] = useState(false); const inputRef = React.useRef(); - const setPopoverOpen = (isOpen: boolean) => { - setIsPopoverOpen(isOpen); - setIsOpenByCreation(isOpen); + const closePopover = () => { + if (isOpenByCreation) { + setIsOpenByCreation(false); + } + if (isPopoverOpen) { + setIsPopoverOpen(false); + } }; const setFilterLabel = (label: string) => setFilter({ ...filter, label }); @@ -57,14 +61,14 @@ export const FilterPopover = ({ panelClassName="lnsIndexPatternDimensionEditor__filtersEditor" isOpen={isOpenByCreation || isPopoverOpen} ownFocus - closePopover={() => { - setPopoverOpen(false); - }} + closePopover={() => closePopover()} button={