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/maps/common/descriptor_types/source_descriptor_types.ts b/x-pack/plugins/maps/common/descriptor_types/source_descriptor_types.ts index a6afbe4d55f9b..c11ee59768a91 100644 --- a/x-pack/plugins/maps/common/descriptor_types/source_descriptor_types.ts +++ b/x-pack/plugins/maps/common/descriptor_types/source_descriptor_types.ts @@ -40,12 +40,28 @@ export type AbstractESSourceDescriptor = AbstractSourceDescriptor & { applyGlobalTime: boolean; }; -export type AggDescriptor = { - field?: string; // count aggregation does not require field. All other aggregation types do - label?: string; +type AbstractAggDescriptor = { type: AGG_TYPE; + label?: string; +}; + +export type CountAggDescriptor = AbstractAggDescriptor & { + type: AGG_TYPE.COUNT; }; +export type FieldedAggDescriptor = AbstractAggDescriptor & { + type: + | AGG_TYPE.UNIQUE_COUNT + | AGG_TYPE.MAX + | AGG_TYPE.MIN + | AGG_TYPE.SUM + | AGG_TYPE.AVG + | AGG_TYPE.TERMS; + field?: string; +}; + +export type AggDescriptor = CountAggDescriptor | FieldedAggDescriptor; + export type AbstractESAggSourceDescriptor = AbstractESSourceDescriptor & { metrics: AggDescriptor[]; }; diff --git a/x-pack/plugins/maps/common/migrations/join_agg_key.ts b/x-pack/plugins/maps/common/migrations/join_agg_key.ts index 4dc70d3c0fa22..b961637218dd6 100644 --- a/x-pack/plugins/maps/common/migrations/join_agg_key.ts +++ b/x-pack/plugins/maps/common/migrations/join_agg_key.ts @@ -82,7 +82,7 @@ export function migrateJoinAggKey({ _.get(joinDescriptor, 'right.metrics', []).forEach((aggDescriptor: AggDescriptor) => { const legacyAggKey = getLegacyAggKey({ aggType: aggDescriptor.type, - aggFieldName: aggDescriptor.field, + aggFieldName: 'field' in aggDescriptor ? aggDescriptor.field : undefined, indexPatternTitle: _.get(joinDescriptor, 'right.indexPatternTitle', ''), termFieldName: _.get(joinDescriptor, 'right.term', ''), }); diff --git a/x-pack/plugins/maps/public/classes/fields/agg/agg_field.test.ts b/x-pack/plugins/maps/public/classes/fields/agg/agg_field.test.ts new file mode 100644 index 0000000000000..49a599326d54c --- /dev/null +++ b/x-pack/plugins/maps/public/classes/fields/agg/agg_field.test.ts @@ -0,0 +1,60 @@ +/* + * 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 { AggField } from './agg_field'; +import { AGG_TYPE, FIELD_ORIGIN } from '../../../../common/constants'; +import { IESAggSource } from '../../sources/es_agg_source'; +import { IIndexPattern } from 'src/plugins/data/public'; + +const mockIndexPattern = { + title: 'wildIndex', + fields: [ + { + name: 'foo*', + }, + ], +} as IIndexPattern; + +const mockEsAggSource = { + getAggKey: (aggType: AGG_TYPE, fieldName: string) => { + return 'agg_key'; + }, + getAggLabel: (aggType: AGG_TYPE, fieldName: string) => { + return 'agg_label'; + }, + getIndexPattern: async () => { + return mockIndexPattern; + }, +} as IESAggSource; + +const defaultParams = { + label: 'my agg field', + source: mockEsAggSource, + origin: FIELD_ORIGIN.SOURCE, +}; + +describe('supportsFieldMeta', () => { + test('Non-counting aggregations should support field meta', () => { + const avgMetric = new AggField({ ...defaultParams, aggType: AGG_TYPE.AVG }); + expect(avgMetric.supportsFieldMeta()).toBe(true); + const maxMetric = new AggField({ ...defaultParams, aggType: AGG_TYPE.MAX }); + expect(maxMetric.supportsFieldMeta()).toBe(true); + const minMetric = new AggField({ ...defaultParams, aggType: AGG_TYPE.MIN }); + expect(minMetric.supportsFieldMeta()).toBe(true); + const termsMetric = new AggField({ ...defaultParams, aggType: AGG_TYPE.TERMS }); + expect(termsMetric.supportsFieldMeta()).toBe(true); + }); + + test('Counting aggregations should not support field meta', () => { + const sumMetric = new AggField({ ...defaultParams, aggType: AGG_TYPE.SUM }); + expect(sumMetric.supportsFieldMeta()).toBe(false); + const uniqueCountMetric = new AggField({ + ...defaultParams, + aggType: AGG_TYPE.UNIQUE_COUNT, + }); + expect(uniqueCountMetric.supportsFieldMeta()).toBe(false); + }); +}); diff --git a/x-pack/plugins/maps/public/classes/fields/agg/agg_field.ts b/x-pack/plugins/maps/public/classes/fields/agg/agg_field.ts new file mode 100644 index 0000000000000..31595327a64b8 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/fields/agg/agg_field.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IndexPattern } from 'src/plugins/data/public'; +import { AGG_TYPE } from '../../../../common/constants'; +import { CountAggField } from './count_agg_field'; +import { isMetricCountable } from '../../util/is_metric_countable'; +import { CountAggFieldParams } from './agg_field_types'; +import { addFieldToDSL, getField } from '../../../../common/elasticsearch_util'; +import { IField } from '../field'; + +const TERMS_AGG_SHARD_SIZE = 5; + +export interface AggFieldParams extends CountAggFieldParams { + esDocField?: IField; + aggType: AGG_TYPE; +} + +export class AggField extends CountAggField { + private readonly _esDocField?: IField; + private readonly _aggType: AGG_TYPE; + + constructor(params: AggFieldParams) { + super(params); + this._esDocField = params.esDocField; + this._aggType = params.aggType; + } + + isValid(): boolean { + return !!this._esDocField; + } + + supportsFieldMeta(): boolean { + // count and sum aggregations are not within field bounds so they do not support field meta. + return !isMetricCountable(this._getAggType()); + } + + canValueBeFormatted(): boolean { + return this._getAggType() !== AGG_TYPE.UNIQUE_COUNT; + } + + _getAggType(): AGG_TYPE { + return this._aggType; + } + + getValueAggDsl(indexPattern: IndexPattern): unknown { + const field = getField(indexPattern, this.getRootName()); + const aggType = this._getAggType(); + const aggBody = aggType === AGG_TYPE.TERMS ? { size: 1, shard_size: TERMS_AGG_SHARD_SIZE } : {}; + return { + [aggType]: addFieldToDSL(aggBody, field), + }; + } + + getRootName(): string { + return this._esDocField ? this._esDocField.getName() : ''; + } + + async getDataType(): Promise { + return this._getAggType() === AGG_TYPE.TERMS ? 'string' : 'number'; + } + + getBucketCount(): number { + // terms aggregation increases the overall number of buckets per split bucket + return this._getAggType() === AGG_TYPE.TERMS ? TERMS_AGG_SHARD_SIZE : 0; + } + + async getOrdinalFieldMetaRequest(): Promise { + return this._esDocField ? await this._esDocField.getOrdinalFieldMetaRequest() : null; + } + + async getCategoricalFieldMetaRequest(size: number): Promise { + return this._esDocField ? await this._esDocField.getCategoricalFieldMetaRequest(size) : null; + } +} diff --git a/x-pack/plugins/maps/public/classes/fields/agg/agg_field_types.ts b/x-pack/plugins/maps/public/classes/fields/agg/agg_field_types.ts new file mode 100644 index 0000000000000..74f03a3f31909 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/fields/agg/agg_field_types.ts @@ -0,0 +1,22 @@ +/* + * 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 { IField } from '../field'; +import { IndexPattern } from '../../../../../../../src/plugins/data/common/index_patterns/index_patterns'; +import { IESAggSource } from '../../sources/es_agg_source'; +import { FIELD_ORIGIN } from '../../../../common/constants'; + +export interface IESAggField extends IField { + getValueAggDsl(indexPattern: IndexPattern): unknown | null; + getBucketCount(): number; +} + +export interface CountAggFieldParams { + label?: string; + source: IESAggSource; + origin: FIELD_ORIGIN; + canReadFromGeoJson?: boolean; +} diff --git a/x-pack/plugins/maps/public/classes/fields/agg/count_agg_field.test.ts b/x-pack/plugins/maps/public/classes/fields/agg/count_agg_field.test.ts new file mode 100644 index 0000000000000..a313b59643c34 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/fields/agg/count_agg_field.test.ts @@ -0,0 +1,45 @@ +/* + * 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 { CountAggField } from './count_agg_field'; +import { AGG_TYPE, FIELD_ORIGIN } from '../../../../common/constants'; +import { IESAggSource } from '../../sources/es_agg_source'; +import { IIndexPattern } from 'src/plugins/data/public'; + +const mockIndexPattern = { + title: 'wildIndex', + fields: [ + { + name: 'foo*', + }, + ], +} as IIndexPattern; + +const mockEsAggSource = { + getAggKey: (aggType: AGG_TYPE, fieldName: string) => { + return 'agg_key'; + }, + getAggLabel: (aggType: AGG_TYPE, fieldName: string) => { + return 'agg_label'; + }, + getIndexPattern: async () => { + return mockIndexPattern; + }, +} as IESAggSource; + +const defaultParams = { + label: 'my agg field', + source: mockEsAggSource, + aggType: AGG_TYPE.COUNT, + origin: FIELD_ORIGIN.SOURCE, +}; + +describe('supportsFieldMeta', () => { + test('Counting aggregations should not support field meta', () => { + const countMetric = new CountAggField({ ...defaultParams }); + expect(countMetric.supportsFieldMeta()).toBe(false); + }); +}); diff --git a/x-pack/plugins/maps/public/classes/fields/agg/count_agg_field.ts b/x-pack/plugins/maps/public/classes/fields/agg/count_agg_field.ts new file mode 100644 index 0000000000000..a4562c91e92a6 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/fields/agg/count_agg_field.ts @@ -0,0 +1,100 @@ +/* + * 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 { IndexPattern } from 'src/plugins/data/public'; +import { IESAggSource } from '../../sources/es_agg_source'; +import { IVectorSource } from '../../sources/vector_source'; +import { AGG_TYPE, FIELD_ORIGIN } from '../../../../common/constants'; +import { ITooltipProperty, TooltipProperty } from '../../tooltips/tooltip_property'; +import { ESAggTooltipProperty } from '../../tooltips/es_agg_tooltip_property'; +import { IESAggField, CountAggFieldParams } from './agg_field_types'; + +// Agg without field. Essentially a count-aggregation. +export class CountAggField implements IESAggField { + private readonly _source: IESAggSource; + private readonly _origin: FIELD_ORIGIN; + private readonly _label?: string; + private readonly _canReadFromGeoJson: boolean; + + constructor({ label, source, origin, canReadFromGeoJson = true }: CountAggFieldParams) { + this._source = source; + this._origin = origin; + this._label = label; + this._canReadFromGeoJson = canReadFromGeoJson; + } + + _getAggType(): AGG_TYPE { + return AGG_TYPE.COUNT; + } + + getSource(): IVectorSource { + return this._source; + } + + getOrigin(): FIELD_ORIGIN { + return this._origin; + } + + getName(): string { + return this._source.getAggKey(this._getAggType(), this.getRootName()); + } + + getRootName(): string { + return ''; + } + + async getLabel(): Promise { + return this._label + ? this._label + : this._source.getAggLabel(this._getAggType(), this.getRootName()); + } + + isValid(): boolean { + return true; + } + + async getDataType(): Promise { + return 'number'; + } + + async createTooltipProperty(value: string | string[] | undefined): Promise { + const indexPattern = await this._source.getIndexPattern(); + const tooltipProperty = new TooltipProperty(this.getName(), await this.getLabel(), value); + return new ESAggTooltipProperty(tooltipProperty, indexPattern, this, this._getAggType()); + } + + getValueAggDsl(indexPattern: IndexPattern): unknown | null { + return null; + } + + supportsFieldMeta(): boolean { + return false; + } + + getBucketCount() { + return 0; + } + + canValueBeFormatted(): boolean { + return false; + } + + async getOrdinalFieldMetaRequest(): Promise { + return null; + } + + async getCategoricalFieldMetaRequest(size: number): Promise { + return null; + } + + supportsAutoDomain(): boolean { + return this._canReadFromGeoJson ? true : this.supportsFieldMeta(); + } + + canReadFromGeoJson(): boolean { + return this._canReadFromGeoJson; + } +} diff --git a/x-pack/plugins/maps/public/classes/fields/agg/es_agg_factory.test.ts b/x-pack/plugins/maps/public/classes/fields/agg/es_agg_factory.test.ts new file mode 100644 index 0000000000000..cb0bb51e374fb --- /dev/null +++ b/x-pack/plugins/maps/public/classes/fields/agg/es_agg_factory.test.ts @@ -0,0 +1,51 @@ +/* + * 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 { esAggFieldsFactory } from './es_agg_factory'; +import { AGG_TYPE, FIELD_ORIGIN } from '../../../../common/constants'; +import { IESAggSource } from '../../sources/es_agg_source'; +import { IIndexPattern } from '../../../../../../../src/plugins/data/common/index_patterns'; + +const mockIndexPattern = { + title: 'wildIndex', + fields: [ + { + name: 'foo*', + }, + ], +} as IIndexPattern; + +const mockEsAggSource = { + getAggKey: (aggType: AGG_TYPE, fieldName: string) => { + return 'agg_key'; + }, + getAggLabel: (aggType: AGG_TYPE, fieldName: string) => { + return 'agg_label'; + }, + getIndexPattern: async () => { + return mockIndexPattern; + }, +} as IESAggSource; + +describe('esAggFieldsFactory', () => { + test('Should only create top terms field when term field is not provided', () => { + const fields = esAggFieldsFactory( + { type: AGG_TYPE.TERMS }, + mockEsAggSource, + FIELD_ORIGIN.SOURCE + ); + expect(fields.length).toBe(1); + }); + + test('Should create top terms and top terms percentage fields', () => { + const fields = esAggFieldsFactory( + { type: AGG_TYPE.TERMS, field: 'myField' }, + mockEsAggSource, + FIELD_ORIGIN.SOURCE + ); + expect(fields.length).toBe(2); + }); +}); diff --git a/x-pack/plugins/maps/public/classes/fields/agg/es_agg_factory.ts b/x-pack/plugins/maps/public/classes/fields/agg/es_agg_factory.ts new file mode 100644 index 0000000000000..a734432d03ca2 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/fields/agg/es_agg_factory.ts @@ -0,0 +1,51 @@ +/* + * 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 { AggDescriptor } from '../../../../common/descriptor_types'; +import { IESAggSource } from '../../sources/es_agg_source'; +import { AGG_TYPE, FIELD_ORIGIN } from '../../../../common/constants'; +import { ESDocField } from '../es_doc_field'; +import { TopTermPercentageField } from './top_term_percentage_field'; +import { CountAggField } from './count_agg_field'; +import { IESAggField } from './agg_field_types'; +import { AggField } from './agg_field'; + +export function esAggFieldsFactory( + aggDescriptor: AggDescriptor, + source: IESAggSource, + origin: FIELD_ORIGIN, + canReadFromGeoJson: boolean = true +): IESAggField[] { + let aggField; + if (aggDescriptor.type === AGG_TYPE.COUNT) { + aggField = new CountAggField({ + label: aggDescriptor.label, + source, + origin, + canReadFromGeoJson, + }); + } else { + aggField = new AggField({ + label: aggDescriptor.label, + esDocField: + 'field' in aggDescriptor && aggDescriptor.field + ? new ESDocField({ fieldName: aggDescriptor.field, source, origin }) + : undefined, + aggType: aggDescriptor.type, + source, + origin, + canReadFromGeoJson, + }); + } + + const aggFields: IESAggField[] = [aggField]; + + if ('field' in aggDescriptor && aggDescriptor.type === AGG_TYPE.TERMS) { + aggFields.push(new TopTermPercentageField(aggField, canReadFromGeoJson)); + } + + return aggFields; +} diff --git a/x-pack/plugins/maps/public/classes/fields/agg/index.ts b/x-pack/plugins/maps/public/classes/fields/agg/index.ts new file mode 100644 index 0000000000000..e6aeb7ba9e7b1 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/fields/agg/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { esAggFieldsFactory } from './es_agg_factory'; +export { IESAggField } from './agg_field_types'; diff --git a/x-pack/plugins/maps/public/classes/fields/top_term_percentage_field.ts b/x-pack/plugins/maps/public/classes/fields/agg/top_term_percentage_field.ts similarity index 86% rename from x-pack/plugins/maps/public/classes/fields/top_term_percentage_field.ts rename to x-pack/plugins/maps/public/classes/fields/agg/top_term_percentage_field.ts index 50db04d08b2aa..e3d62afaca921 100644 --- a/x-pack/plugins/maps/public/classes/fields/top_term_percentage_field.ts +++ b/x-pack/plugins/maps/public/classes/fields/agg/top_term_percentage_field.ts @@ -4,11 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { IESAggField } from './es_agg_field'; -import { IVectorSource } from '../sources/vector_source'; -import { ITooltipProperty, TooltipProperty } from '../tooltips/tooltip_property'; -import { TOP_TERM_PERCENTAGE_SUFFIX } from '../../../common/constants'; -import { FIELD_ORIGIN } from '../../../common/constants'; +import { IESAggField } from './agg_field_types'; +import { IVectorSource } from '../../sources/vector_source'; +import { ITooltipProperty, TooltipProperty } from '../../tooltips/tooltip_property'; +import { TOP_TERM_PERCENTAGE_SUFFIX, FIELD_ORIGIN } from '../../../../common/constants'; export class TopTermPercentageField implements IESAggField { private readonly _topTermAggField: IESAggField; diff --git a/x-pack/plugins/maps/public/classes/fields/es_agg_field.test.ts b/x-pack/plugins/maps/public/classes/fields/es_agg_field.test.ts deleted file mode 100644 index 7a65b5f9f6b46..0000000000000 --- a/x-pack/plugins/maps/public/classes/fields/es_agg_field.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { ESAggField, esAggFieldsFactory } from './es_agg_field'; -import { AGG_TYPE, FIELD_ORIGIN } from '../../../common/constants'; -import { IESAggSource } from '../sources/es_agg_source'; -import { IIndexPattern } from 'src/plugins/data/public'; - -const mockIndexPattern = { - title: 'wildIndex', - fields: [ - { - name: 'foo*', - }, - ], -} as IIndexPattern; - -const mockEsAggSource = { - getAggKey: (aggType: AGG_TYPE, fieldName: string) => { - return 'agg_key'; - }, - getAggLabel: (aggType: AGG_TYPE, fieldName: string) => { - return 'agg_label'; - }, - getIndexPattern: async () => { - return mockIndexPattern; - }, -} as IESAggSource; - -const defaultParams = { - label: 'my agg field', - source: mockEsAggSource, - aggType: AGG_TYPE.COUNT, - origin: FIELD_ORIGIN.SOURCE, -}; - -describe('supportsFieldMeta', () => { - test('Non-counting aggregations should support field meta', () => { - const avgMetric = new ESAggField({ ...defaultParams, aggType: AGG_TYPE.AVG }); - expect(avgMetric.supportsFieldMeta()).toBe(true); - const maxMetric = new ESAggField({ ...defaultParams, aggType: AGG_TYPE.MAX }); - expect(maxMetric.supportsFieldMeta()).toBe(true); - const minMetric = new ESAggField({ ...defaultParams, aggType: AGG_TYPE.MIN }); - expect(minMetric.supportsFieldMeta()).toBe(true); - const termsMetric = new ESAggField({ ...defaultParams, aggType: AGG_TYPE.TERMS }); - expect(termsMetric.supportsFieldMeta()).toBe(true); - }); - - test('Counting aggregations should not support field meta', () => { - const countMetric = new ESAggField({ ...defaultParams, aggType: AGG_TYPE.COUNT }); - expect(countMetric.supportsFieldMeta()).toBe(false); - const sumMetric = new ESAggField({ ...defaultParams, aggType: AGG_TYPE.SUM }); - expect(sumMetric.supportsFieldMeta()).toBe(false); - const uniqueCountMetric = new ESAggField({ ...defaultParams, aggType: AGG_TYPE.UNIQUE_COUNT }); - expect(uniqueCountMetric.supportsFieldMeta()).toBe(false); - }); -}); - -describe('esAggFieldsFactory', () => { - test('Should only create top terms field when term field is not provided', () => { - const fields = esAggFieldsFactory( - { type: AGG_TYPE.TERMS }, - mockEsAggSource, - FIELD_ORIGIN.SOURCE - ); - expect(fields.length).toBe(1); - }); - - test('Should create top terms and top terms percentage fields', () => { - const fields = esAggFieldsFactory( - { type: AGG_TYPE.TERMS, field: 'myField' }, - mockEsAggSource, - FIELD_ORIGIN.SOURCE - ); - expect(fields.length).toBe(2); - }); -}); diff --git a/x-pack/plugins/maps/public/classes/fields/es_agg_field.ts b/x-pack/plugins/maps/public/classes/fields/es_agg_field.ts deleted file mode 100644 index 8cff98205186f..0000000000000 --- a/x-pack/plugins/maps/public/classes/fields/es_agg_field.ts +++ /dev/null @@ -1,171 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { IndexPattern } from 'src/plugins/data/public'; -import { IField } from './field'; -import { AggDescriptor } from '../../../common/descriptor_types'; -import { IESAggSource } from '../sources/es_agg_source'; -import { IVectorSource } from '../sources/vector_source'; -import { ESDocField } from './es_doc_field'; -import { AGG_TYPE, FIELD_ORIGIN } from '../../../common/constants'; -import { isMetricCountable } from '../util/is_metric_countable'; -import { getField, addFieldToDSL } from '../../../common/elasticsearch_util'; -import { TopTermPercentageField } from './top_term_percentage_field'; -import { ITooltipProperty, TooltipProperty } from '../tooltips/tooltip_property'; -import { ESAggTooltipProperty } from '../tooltips/es_agg_tooltip_property'; - -const TERMS_AGG_SHARD_SIZE = 5; - -export interface IESAggField extends IField { - getValueAggDsl(indexPattern: IndexPattern): unknown | null; - getBucketCount(): number; -} - -export class ESAggField implements IESAggField { - private readonly _source: IESAggSource; - private readonly _origin: FIELD_ORIGIN; - private readonly _label?: string; - private readonly _aggType: AGG_TYPE; - private readonly _esDocField?: IField | undefined; - private readonly _canReadFromGeoJson: boolean; - - constructor({ - label, - source, - aggType, - esDocField, - origin, - canReadFromGeoJson = true, - }: { - label?: string; - source: IESAggSource; - aggType: AGG_TYPE; - esDocField?: IField; - origin: FIELD_ORIGIN; - canReadFromGeoJson?: boolean; - }) { - this._source = source; - this._origin = origin; - this._label = label; - this._aggType = aggType; - this._esDocField = esDocField; - this._canReadFromGeoJson = canReadFromGeoJson; - } - - getSource(): IVectorSource { - return this._source; - } - - getOrigin(): FIELD_ORIGIN { - return this._origin; - } - - getName(): string { - return this._source.getAggKey(this.getAggType(), this.getRootName()); - } - - getRootName(): string { - return this._getESDocFieldName(); - } - - async getLabel(): Promise { - return this._label - ? this._label - : this._source.getAggLabel(this.getAggType(), this.getRootName()); - } - - getAggType(): AGG_TYPE { - return this._aggType; - } - - isValid(): boolean { - return this.getAggType() === AGG_TYPE.COUNT ? true : !!this._esDocField; - } - - async getDataType(): Promise { - return this.getAggType() === AGG_TYPE.TERMS ? 'string' : 'number'; - } - - _getESDocFieldName(): string { - return this._esDocField ? this._esDocField.getName() : ''; - } - - async createTooltipProperty(value: string | string[] | undefined): Promise { - const indexPattern = await this._source.getIndexPattern(); - const tooltipProperty = new TooltipProperty(this.getName(), await this.getLabel(), value); - return new ESAggTooltipProperty(tooltipProperty, indexPattern, this, this.getAggType()); - } - - getValueAggDsl(indexPattern: IndexPattern): unknown | null { - if (this.getAggType() === AGG_TYPE.COUNT) { - return null; - } - - const field = getField(indexPattern, this.getRootName()); - const aggType = this.getAggType(); - const aggBody = aggType === AGG_TYPE.TERMS ? { size: 1, shard_size: TERMS_AGG_SHARD_SIZE } : {}; - return { - [aggType]: addFieldToDSL(aggBody, field), - }; - } - - getBucketCount(): number { - // terms aggregation increases the overall number of buckets per split bucket - return this.getAggType() === AGG_TYPE.TERMS ? TERMS_AGG_SHARD_SIZE : 0; - } - - supportsFieldMeta(): boolean { - // count and sum aggregations are not within field bounds so they do not support field meta. - return !isMetricCountable(this.getAggType()); - } - - canValueBeFormatted(): boolean { - // Do not use field formatters for counting metrics - return ![AGG_TYPE.COUNT, AGG_TYPE.UNIQUE_COUNT].includes(this.getAggType()); - } - - async getOrdinalFieldMetaRequest(): Promise { - return this._esDocField ? this._esDocField.getOrdinalFieldMetaRequest() : null; - } - - async getCategoricalFieldMetaRequest(size: number): Promise { - return this._esDocField ? this._esDocField.getCategoricalFieldMetaRequest(size) : null; - } - - supportsAutoDomain(): boolean { - return this._canReadFromGeoJson ? true : this.supportsFieldMeta(); - } - - canReadFromGeoJson(): boolean { - return this._canReadFromGeoJson; - } -} - -export function esAggFieldsFactory( - aggDescriptor: AggDescriptor, - source: IESAggSource, - origin: FIELD_ORIGIN, - canReadFromGeoJson: boolean = true -): IESAggField[] { - const aggField = new ESAggField({ - label: aggDescriptor.label, - esDocField: aggDescriptor.field - ? new ESDocField({ fieldName: aggDescriptor.field, source, origin }) - : undefined, - aggType: aggDescriptor.type, - source, - origin, - canReadFromGeoJson, - }); - - const aggFields: IESAggField[] = [aggField]; - - if (aggDescriptor.field && aggDescriptor.type === AGG_TYPE.TERMS) { - aggFields.push(new TopTermPercentageField(aggField, canReadFromGeoJson)); - } - - return aggFields; -} diff --git a/x-pack/plugins/maps/public/classes/layers/choropleth_layer_wizard/create_choropleth_layer_descriptor.ts b/x-pack/plugins/maps/public/classes/layers/choropleth_layer_wizard/create_choropleth_layer_descriptor.ts index 43d1d39c170c0..cdfe60946f5f9 100644 --- a/x-pack/plugins/maps/public/classes/layers/choropleth_layer_wizard/create_choropleth_layer_descriptor.ts +++ b/x-pack/plugins/maps/public/classes/layers/choropleth_layer_wizard/create_choropleth_layer_descriptor.ts @@ -16,8 +16,8 @@ import { } from '../../../../common/constants'; import { getJoinAggKey } from '../../../../common/get_agg_key'; import { - AggDescriptor, ColorDynamicOptions, + CountAggDescriptor, EMSFileSourceDescriptor, ESSearchSourceDescriptor, } from '../../../../common/descriptor_types'; @@ -43,11 +43,11 @@ function createChoroplethLayerDescriptor({ rightIndexPatternTitle: string; rightTermField: string; }) { - const metricsDescriptor: AggDescriptor = { type: AGG_TYPE.COUNT }; + const metricsDescriptor: CountAggDescriptor = { type: AGG_TYPE.COUNT }; const joinId = uuid(); const joinKey = getJoinAggKey({ aggType: metricsDescriptor.type, - aggFieldName: metricsDescriptor.field ? metricsDescriptor.field : '', + aggFieldName: '', rightSourceId: joinId, }); return VectorLayer.createDescriptor({ diff --git a/x-pack/plugins/maps/public/classes/layers/create_region_map_layer_descriptor.ts b/x-pack/plugins/maps/public/classes/layers/create_region_map_layer_descriptor.ts index 8830831b8b656..6f9bb686459b5 100644 --- a/x-pack/plugins/maps/public/classes/layers/create_region_map_layer_descriptor.ts +++ b/x-pack/plugins/maps/public/classes/layers/create_region_map_layer_descriptor.ts @@ -69,7 +69,7 @@ export function createRegionMapLayerDescriptor({ const joinId = uuid(); const joinKey = getJoinAggKey({ aggType: metricsDescriptor.type, - aggFieldName: metricsDescriptor.field ? metricsDescriptor.field : '', + aggFieldName: 'field' in metricsDescriptor ? metricsDescriptor.field : '', rightSourceId: joinId, }); const colorPallette = NUMERICAL_COLOR_PALETTES.find((pallette) => { diff --git a/x-pack/plugins/maps/public/classes/layers/create_tile_map_layer_descriptor.ts b/x-pack/plugins/maps/public/classes/layers/create_tile_map_layer_descriptor.ts index 05a8620e436d5..5b89373f2db48 100644 --- a/x-pack/plugins/maps/public/classes/layers/create_tile_map_layer_descriptor.ts +++ b/x-pack/plugins/maps/public/classes/layers/create_tile_map_layer_descriptor.ts @@ -103,7 +103,7 @@ export function createTileMapLayerDescriptor({ const metricSourceKey = getSourceAggKey({ aggType: metricsDescriptor.type, - aggFieldName: metricsDescriptor.field, + aggFieldName: 'field' in metricsDescriptor ? metricsDescriptor.field : '', }); const metricStyleField = { name: metricSourceKey, diff --git a/x-pack/plugins/maps/public/classes/layers/layer.tsx b/x-pack/plugins/maps/public/classes/layers/layer.tsx index 7c76df7f6e877..b982e6452e8cb 100644 --- a/x-pack/plugins/maps/public/classes/layers/layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/layer.tsx @@ -173,12 +173,12 @@ export class AbstractLayer implements ILayer { metrics.forEach((metricsDescriptor: AggDescriptor) => { const originalJoinKey = getJoinAggKey({ aggType: metricsDescriptor.type, - aggFieldName: metricsDescriptor.field ? metricsDescriptor.field : '', + aggFieldName: 'field' in metricsDescriptor ? metricsDescriptor.field : '', rightSourceId: originalJoinId, }); const newJoinKey = getJoinAggKey({ aggType: metricsDescriptor.type, - aggFieldName: metricsDescriptor.field ? metricsDescriptor.field : '', + aggFieldName: 'field' in metricsDescriptor ? metricsDescriptor.field : '', rightSourceId: joinDescriptor.right.id!, }); diff --git a/x-pack/plugins/maps/public/classes/layers/solution_layers/observability/create_layer_descriptor.ts b/x-pack/plugins/maps/public/classes/layers/solution_layers/observability/create_layer_descriptor.ts index 5dbf07ed2a535..dea551866f4a9 100644 --- a/x-pack/plugins/maps/public/classes/layers/solution_layers/observability/create_layer_descriptor.ts +++ b/x-pack/plugins/maps/public/classes/layers/solution_layers/observability/create_layer_descriptor.ts @@ -161,7 +161,7 @@ export function createLayerDescriptor({ const joinId = uuid(); const joinKey = getJoinAggKey({ aggType: metricsDescriptor.type, - aggFieldName: metricsDescriptor.field ? metricsDescriptor.field : '', + aggFieldName: 'field' in metricsDescriptor ? metricsDescriptor.field : '', rightSourceId: joinId, }); return VectorLayer.createDescriptor({ @@ -219,7 +219,7 @@ export function createLayerDescriptor({ const metricSourceKey = getSourceAggKey({ aggType: metricsDescriptor.type, - aggFieldName: metricsDescriptor.field, + aggFieldName: 'field' in metricsDescriptor ? metricsDescriptor.field : undefined, }); const metricStyleField = { name: metricSourceKey, diff --git a/x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.test.ts b/x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.test.ts index d31e8366e4ef4..a731fcee3f6f5 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.test.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.test.ts @@ -6,7 +6,7 @@ import { AbstractESAggSource } from '../es_agg_source'; import { IField } from '../../fields/field'; -import { IESAggField } from '../../fields/es_agg_field'; +import { IESAggField } from '../../fields/agg'; import _ from 'lodash'; import { AGG_TYPE } from '../../../../common/constants'; import { AggDescriptor } from '../../../../common/descriptor_types'; diff --git a/x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.ts b/x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.ts index dc95632032fa9..b88ae9a4727a8 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.ts @@ -9,9 +9,8 @@ import { Adapters } from 'src/plugins/inspector/public'; import { GeoJsonProperties } from 'geojson'; import { IESSource } from '../es_source'; import { AbstractESSource } from '../es_source'; -import { esAggFieldsFactory } from '../../fields/es_agg_field'; +import { esAggFieldsFactory, IESAggField } from '../../fields/agg'; import { AGG_TYPE, COUNT_PROP_LABEL, FIELD_ORIGIN } from '../../../../common/constants'; -import { IESAggField } from '../../fields/es_agg_field'; import { getSourceAggKey } from '../../../../common/get_agg_key'; import { AbstractESAggSourceDescriptor, AggDescriptor } from '../../../../common/descriptor_types'; import { IndexPattern } from '../../../../../../../src/plugins/data/public'; diff --git a/x-pack/plugins/maps/public/components/metrics_editor/metric_editor.tsx b/x-pack/plugins/maps/public/components/metrics_editor/metric_editor.tsx index 543d144efdcc7..61d8a143549e1 100644 --- a/x-pack/plugins/maps/public/components/metrics_editor/metric_editor.tsx +++ b/x-pack/plugins/maps/public/components/metrics_editor/metric_editor.tsx @@ -57,30 +57,31 @@ export function MetricEditor({ if (!metricAggregationType) { return; } - const newMetricProps = { - ...metric, + + const descriptor = { type: metricAggregationType, + label: metric.label, }; - // unset field when new agg type does not support currently selected field. - if (metric.field && metricAggregationType !== AGG_TYPE.COUNT) { - const fieldsForNewAggType = filterFieldsForAgg(fields, metricAggregationType); - const found = fieldsForNewAggType.find((field) => { - return field.name === metric.field; - }); - if (!found) { - newMetricProps.field = undefined; - } + if (metricAggregationType === AGG_TYPE.COUNT || !('field' in metric) || !metric.field) { + onChange(descriptor); + return; } - onChange(newMetricProps); + const fieldsForNewAggType = filterFieldsForAgg(fields, metricAggregationType); + const found = fieldsForNewAggType.find((field) => field.name === metric.field); + onChange({ + ...descriptor, + field: found ? metric.field : undefined, + }); }; const onFieldChange = (fieldName?: string) => { - if (!fieldName) { + if (!fieldName || metric.type === AGG_TYPE.COUNT) { return; } onChange({ - ...metric, + label: metric.label, + type: metric.type, field: fieldName, }); }; diff --git a/x-pack/plugins/security/common/licensing/index.mock.ts b/x-pack/plugins/security/common/licensing/index.mock.ts index 87225f479ceed..df7d8cd7b416b 100644 --- a/x-pack/plugins/security/common/licensing/index.mock.ts +++ b/x-pack/plugins/security/common/licensing/index.mock.ts @@ -11,6 +11,7 @@ export const licenseMock = { create: (): jest.Mocked => ({ isLicenseAvailable: jest.fn(), isEnabled: jest.fn().mockReturnValue(true), + getType: jest.fn().mockReturnValue('basic'), getFeatures: jest.fn(), features$: of(), }), diff --git a/x-pack/plugins/security/common/licensing/license_service.ts b/x-pack/plugins/security/common/licensing/license_service.ts index 09b6ae95c282c..ca6366ef0bb8e 100644 --- a/x-pack/plugins/security/common/licensing/license_service.ts +++ b/x-pack/plugins/security/common/licensing/license_service.ts @@ -6,12 +6,13 @@ import { Observable, Subscription } from 'rxjs'; import { map } from 'rxjs/operators'; -import { ILicense } from '../../../licensing/common/types'; +import { ILicense, LicenseType } from '../../../licensing/common/types'; import { SecurityLicenseFeatures } from './license_features'; export interface SecurityLicense { isLicenseAvailable(): boolean; isEnabled(): boolean; + getType(): LicenseType | undefined; getFeatures(): SecurityLicenseFeatures; features$: Observable; } @@ -36,6 +37,8 @@ export class SecurityLicenseService { isEnabled: () => this.isSecurityEnabledFromRawLicense(rawLicense), + getType: () => rawLicense?.type, + getFeatures: () => this.calculateFeaturesFromRawLicense(rawLicense), features$: license$.pipe( diff --git a/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_privileges.ts b/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_privileges.ts index 02a18039cee74..66e8eff323313 100644 --- a/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_privileges.ts +++ b/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_privileges.ts @@ -23,6 +23,7 @@ export const createRawKibanaPrivileges = ( const licensingService = { getFeatures: () => ({ allowSubFeaturePrivileges } as SecurityLicenseFeatures), + getType: () => 'basic' as const, }; return privilegesFactory( diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/__snapshots__/elasticsearch_privileges.test.tsx.snap b/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/__snapshots__/elasticsearch_privileges.test.tsx.snap index a2e46af19bf34..173ae4f081ed7 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/__snapshots__/elasticsearch_privileges.test.tsx.snap +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/es/__snapshots__/elasticsearch_privileges.test.tsx.snap @@ -183,6 +183,7 @@ exports[`it renders without crashing 1`] = ` "_subscribe": [Function], }, "getFeatures": [MockFunction], + "getType": [MockFunction], "isEnabled": [MockFunction], "isLicenseAvailable": [MockFunction], } diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.test.tsx index eba94338d52d3..e08871d1a645c 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.test.tsx @@ -14,6 +14,7 @@ import { mountWithIntl } from '@kbn/test/jest'; import { SubFeatureForm } from './sub_feature_form'; import { EuiCheckbox, EuiButtonGroup } from '@elastic/eui'; import { act } from '@testing-library/react'; +import { KibanaFeature } from '../../../../../../../../features/public'; // Note: these tests are not concerned with the proper display of privileges, // as that is verified by the feature_table and privilege_space_form tests. @@ -234,4 +235,65 @@ describe('SubFeatureForm', () => { expect(onChange).toHaveBeenCalledWith([]); }); + + it('does not render empty privilege groups', () => { + // privilege groups are filtered server-side to only include the + // sub-feature privileges that are allowed by the current license. + + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['cool_all'], + }, + spaces: [], + }, + ]); + const feature = new KibanaFeature({ + id: 'test_feature', + name: 'test feature', + category: { id: 'test', label: 'test' }, + app: [], + privileges: { + all: { + savedObject: { all: [], read: [] }, + ui: [], + }, + read: { + savedObject: { all: [], read: [] }, + ui: [], + }, + }, + subFeatures: [ + { + name: 'subFeature1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [], + }, + ], + }, + ], + }); + const subFeature1 = new SecuredSubFeature(feature.toRaw().subFeatures![0]); + const kibanaPrivileges = createKibanaPrivileges([feature]); + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + const onChange = jest.fn(); + + const wrapper = mountWithIntl( + + ); + + expect(wrapper.children()).toMatchInlineSnapshot(`null`); + }); }); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.tsx index 41e15387f9c47..9ffb50066a58e 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.tsx @@ -27,12 +27,20 @@ interface Props { } export const SubFeatureForm = (props: Props) => { + const groupsWithPrivileges = props.subFeature + .getPrivilegeGroups() + .filter((group) => group.privileges.length > 0); + + if (groupsWithPrivileges.length === 0) { + return null; + } + return ( {props.subFeature.name} - {props.subFeature.getPrivilegeGroups().map(renderPrivilegeGroup)} + {groupsWithPrivileges.map(renderPrivilegeGroup)} ); diff --git a/x-pack/plugins/security/public/plugin.test.tsx b/x-pack/plugins/security/public/plugin.test.tsx index 6f5a2a031a7b2..d16662922f696 100644 --- a/x-pack/plugins/security/public/plugin.test.tsx +++ b/x-pack/plugins/security/public/plugin.test.tsx @@ -45,6 +45,7 @@ describe('Security Plugin', () => { license: { isLicenseAvailable: expect.any(Function), isEnabled: expect.any(Function), + getType: expect.any(Function), getFeatures: expect.any(Function), features$: expect.any(Observable), }, @@ -73,6 +74,7 @@ describe('Security Plugin', () => { license: { isLicenseAvailable: expect.any(Function), isEnabled: expect.any(Function), + getType: expect.any(Function), getFeatures: expect.any(Function), features$: expect.any(Observable), }, diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.test.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.test.ts index bdf2c87f40f0b..4f545fd5d17a5 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.test.ts @@ -20,6 +20,7 @@ describe('featurePrivilegeIterator', () => { const actualPrivileges = Array.from( featurePrivilegeIterator(feature, { augmentWithSubFeaturePrivileges: true, + licenseType: 'basic', }) ); @@ -72,6 +73,7 @@ describe('featurePrivilegeIterator', () => { const actualPrivileges = Array.from( featurePrivilegeIterator(feature, { augmentWithSubFeaturePrivileges: true, + licenseType: 'basic', }) ); @@ -164,6 +166,7 @@ describe('featurePrivilegeIterator', () => { const actualPrivileges = Array.from( featurePrivilegeIterator(feature, { augmentWithSubFeaturePrivileges: true, + licenseType: 'basic', predicate: (privilegeId) => privilegeId === 'all', }) ); @@ -270,6 +273,7 @@ describe('featurePrivilegeIterator', () => { const actualPrivileges = Array.from( featurePrivilegeIterator(feature, { augmentWithSubFeaturePrivileges: false, + licenseType: 'basic', }) ); @@ -394,6 +398,7 @@ describe('featurePrivilegeIterator', () => { const actualPrivileges = Array.from( featurePrivilegeIterator(feature, { augmentWithSubFeaturePrivileges: true, + licenseType: 'basic', }) ); @@ -519,6 +524,7 @@ describe('featurePrivilegeIterator', () => { const actualPrivileges = Array.from( featurePrivilegeIterator(feature, { augmentWithSubFeaturePrivileges: true, + licenseType: 'basic', }) ); @@ -645,6 +651,7 @@ describe('featurePrivilegeIterator', () => { const actualPrivileges = Array.from( featurePrivilegeIterator(feature, { augmentWithSubFeaturePrivileges: true, + licenseType: 'basic', }) ); @@ -771,6 +778,7 @@ describe('featurePrivilegeIterator', () => { const actualPrivileges = Array.from( featurePrivilegeIterator(feature, { augmentWithSubFeaturePrivileges: true, + licenseType: 'basic', }) ); @@ -818,6 +826,133 @@ describe('featurePrivilegeIterator', () => { ]); }); + it('excludes sub feature privileges when the minimum license is not met', () => { + const feature = new KibanaFeature({ + id: 'foo', + name: 'foo', + app: [], + category: { id: 'foo', label: 'foo' }, + privileges: { + all: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + alerting: { + all: ['alerting-all-type'], + read: ['alerting-read-type'], + }, + ui: ['ui-action'], + }, + read: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + alerting: { + read: ['alerting-read-type'], + }, + ui: ['ui-action'], + }, + }, + subFeatures: [ + { + name: 'sub feature 1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-feature-priv-1', + name: 'first sub feature privilege', + includeIn: 'all', + minimumLicense: 'gold', + api: ['sub-feature-api'], + app: ['sub-app'], + catalogue: ['sub-catalogue'], + management: { + section: ['other-sub-management'], + kibana: ['sub-management'], + }, + savedObject: { + all: ['all-sub-type'], + read: ['read-sub-type'], + }, + alerting: { + all: ['alerting-all-sub-type'], + read: ['alerting-read-sub-type'], + }, + ui: ['ui-sub-type'], + }, + ], + }, + ], + }, + ], + }); + + const actualPrivileges = Array.from( + featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: true, + licenseType: 'basic', + }) + ); + + expect(actualPrivileges).toEqual([ + { + privilegeId: 'all', + privilege: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + alerting: { + all: ['alerting-all-type'], + read: ['alerting-read-type'], + }, + ui: ['ui-action'], + }, + }, + { + privilegeId: 'read', + privilege: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + alerting: { + read: ['alerting-read-type'], + }, + ui: ['ui-action'], + }, + }, + ]); + }); + it(`can augment primary feature privileges even if they don't specify their own`, () => { const feature = new KibanaFeature({ id: 'foo', @@ -878,6 +1013,7 @@ describe('featurePrivilegeIterator', () => { const actualPrivileges = Array.from( featurePrivilegeIterator(feature, { augmentWithSubFeaturePrivileges: true, + licenseType: 'basic', }) ); @@ -995,6 +1131,7 @@ describe('featurePrivilegeIterator', () => { const actualPrivileges = Array.from( featurePrivilegeIterator(feature, { augmentWithSubFeaturePrivileges: true, + licenseType: 'basic', }) ); diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.ts index dba33f7a4f360..47216baea2025 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.ts @@ -5,11 +5,13 @@ */ import _ from 'lodash'; +import { LicenseType } from '../../../../../licensing/server'; import { KibanaFeature, FeatureKibanaPrivileges } from '../../../../../features/server'; import { subFeaturePrivilegeIterator } from './sub_feature_privilege_iterator'; interface IteratorOptions { augmentWithSubFeaturePrivileges: boolean; + licenseType: LicenseType; predicate?: (privilegeId: string, privilege: FeatureKibanaPrivileges) => boolean; } @@ -25,7 +27,10 @@ export function* featurePrivilegeIterator( } if (options.augmentWithSubFeaturePrivileges) { - yield { privilegeId, privilege: mergeWithSubFeatures(privilegeId, privilege, feature) }; + yield { + privilegeId, + privilege: mergeWithSubFeatures(privilegeId, privilege, feature, options.licenseType), + }; } else { yield { privilegeId, privilege }; } @@ -35,10 +40,11 @@ export function* featurePrivilegeIterator( function mergeWithSubFeatures( privilegeId: string, privilege: FeatureKibanaPrivileges, - feature: KibanaFeature + feature: KibanaFeature, + licenseType: LicenseType ) { const mergedConfig = _.cloneDeep(privilege); - for (const subFeaturePrivilege of subFeaturePrivilegeIterator(feature)) { + for (const subFeaturePrivilege of subFeaturePrivilegeIterator(feature, licenseType)) { if (subFeaturePrivilege.includeIn !== 'read' && subFeaturePrivilege.includeIn !== privilegeId) { continue; } diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/sub_feature_privilege_iterator.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/sub_feature_privilege_iterator.ts index d54b6d458d913..3a282eb8279f0 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/sub_feature_privilege_iterator.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/sub_feature_privilege_iterator.ts @@ -4,14 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ +import { LicenseType } from '../../../../../licensing/server'; + import { KibanaFeature, SubFeaturePrivilegeConfig } from '../../../../../features/common'; export function* subFeaturePrivilegeIterator( - feature: KibanaFeature + feature: KibanaFeature, + licenseType: LicenseType ): IterableIterator { for (const subFeature of feature.subFeatures) { for (const group of subFeature.privilegeGroups) { - yield* group.privileges; + yield* group.privileges.filter( + (privilege) => !privilege.minimumLicense || privilege.minimumLicense <= licenseType + ); } } } diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts index c7b015b001ccf..d9b712025c064 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts @@ -48,6 +48,7 @@ describe('features', () => { const mockLicenseService = { getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + getType: jest.fn().mockReturnValue('basic'), }; const privileges = privilegesFactory(actions, mockFeaturesService, mockLicenseService); @@ -89,6 +90,7 @@ describe('features', () => { }; const mockLicenseService = { getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + getType: jest.fn().mockReturnValue('basic'), }; const privileges = privilegesFactory(actions, mockFeaturesPlugin as any, mockLicenseService); @@ -177,6 +179,7 @@ describe('features', () => { }; const mockLicenseService = { getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + getType: jest.fn().mockReturnValue('basic'), }; const privileges = privilegesFactory(actions, mockFeaturesPlugin as any, mockLicenseService); @@ -245,6 +248,7 @@ describe('features', () => { }; const mockLicenseService = { getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + getType: jest.fn().mockReturnValue('basic'), }; const privileges = privilegesFactory(actions, mockFeaturesPlugin as any, mockLicenseService); @@ -368,6 +372,7 @@ describe('features', () => { }; const mockLicenseService = { getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + getType: jest.fn().mockReturnValue('basic'), }; const privileges = privilegesFactory(actions, mockFeaturesPlugin as any, mockLicenseService); @@ -443,6 +448,7 @@ describe('features', () => { }; const mockLicenseService = { getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + getType: jest.fn().mockReturnValue('basic'), }; const privileges = privilegesFactory(actions, mockFeaturesPlugin as any, mockLicenseService); @@ -508,6 +514,7 @@ describe('features', () => { }; const mockLicenseService = { getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + getType: jest.fn().mockReturnValue('basic'), }; const privileges = privilegesFactory(actions, mockFeaturesPlugin as any, mockLicenseService); @@ -574,6 +581,7 @@ describe('features', () => { }; const mockLicenseService = { getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + getType: jest.fn().mockReturnValue('basic'), }; const privileges = privilegesFactory(actions, mockFeaturesPlugin as any, mockLicenseService); @@ -633,6 +641,7 @@ describe('reserved', () => { }; const mockLicenseService = { getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + getType: jest.fn().mockReturnValue('basic'), }; const privileges = privilegesFactory(actions, mockFeaturesPlugin as any, mockLicenseService); @@ -671,6 +680,7 @@ describe('reserved', () => { }; const mockLicenseService = { getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + getType: jest.fn().mockReturnValue('basic'), }; const privileges = privilegesFactory(actions, mockFeaturesPlugin as any, mockLicenseService); @@ -737,6 +747,7 @@ describe('reserved', () => { }; const mockLicenseService = { getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + getType: jest.fn().mockReturnValue('basic'), }; const privileges = privilegesFactory(actions, mockFeaturesPlugin as any, mockLicenseService); @@ -800,6 +811,7 @@ describe('subFeatures', () => { }; const mockLicenseService = { getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + getType: jest.fn().mockReturnValue('basic'), }; const privileges = privilegesFactory(actions, mockFeaturesPlugin as any, mockLicenseService); @@ -928,6 +940,7 @@ describe('subFeatures', () => { }; const mockLicenseService = { getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + getType: jest.fn().mockReturnValue('basic'), }; const privileges = privilegesFactory(actions, mockFeaturesPlugin as any, mockLicenseService); @@ -1135,6 +1148,7 @@ describe('subFeatures', () => { }; const mockLicenseService = { getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + getType: jest.fn().mockReturnValue('basic'), }; const privileges = privilegesFactory(actions, mockFeaturesPlugin as any, mockLicenseService); @@ -1278,6 +1292,7 @@ describe('subFeatures', () => { }; const mockLicenseService = { getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + getType: jest.fn().mockReturnValue('basic'), }; const privileges = privilegesFactory(actions, mockFeaturesPlugin as any, mockLicenseService); @@ -1446,6 +1461,7 @@ describe('subFeatures', () => { }; const mockLicenseService = { getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + getType: jest.fn().mockReturnValue('basic'), }; const privileges = privilegesFactory(actions, mockFeaturesPlugin as any, mockLicenseService); @@ -1576,6 +1592,7 @@ describe('subFeatures', () => { }; const mockLicenseService = { getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: false }), + getType: jest.fn().mockReturnValue('basic'), }; const privileges = privilegesFactory(actions, mockFeaturesPlugin as any, mockLicenseService); @@ -1705,4 +1722,502 @@ describe('subFeatures', () => { ]); }); }); + + describe(`when license allows subfeatures, but not a specific sub feature`, () => { + test(`should create minimal privileges, but not augment the primary feature privileges or create the disallowed sub-feature privileges`, () => { + const features: KibanaFeature[] = [ + new KibanaFeature({ + id: 'foo', + name: 'Foo KibanaFeature', + app: [], + category: { id: 'foo', label: 'foo' }, + privileges: { + all: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + }, + subFeatures: [ + { + name: 'subFeature1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'subFeaturePriv1', + name: 'sub feature priv 1', + includeIn: 'read', + savedObject: { + all: ['all-sub-feature-type'], + read: ['read-sub-feature-type'], + }, + ui: ['sub-feature-ui'], + }, + ], + }, + { + groupType: 'independent', + privileges: [ + { + id: 'licensedSubFeaturePriv', + name: 'licensed sub feature priv', + includeIn: 'read', + minimumLicense: 'platinum', + savedObject: { + all: ['all-licensed-sub-feature-type'], + read: ['read-licensed-sub-feature-type'], + }, + ui: ['licensed-sub-feature-ui'], + }, + ], + }, + ], + }, + ], + }), + ]; + + const mockFeaturesPlugin = { + getKibanaFeatures: jest.fn().mockReturnValue(features), + }; + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + getType: jest.fn().mockReturnValue('gold'), + }; + const privileges = privilegesFactory(actions, mockFeaturesPlugin as any, mockLicenseService); + + const actual = privileges.get(); + expect(actual.features).toHaveProperty(`foo.subFeaturePriv1`); + expect(actual.features).not.toHaveProperty(`foo.licensedSubFeaturePriv`); + + expect(actual.features).toHaveProperty(`foo.all`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty(`foo.minimal_all`, [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual.features).toHaveProperty(`foo.read`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty(`foo.minimal_read`, [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual).toHaveProperty('global.all', [ + actions.login, + actions.version, + actions.api.get('features'), + actions.space.manage, + actions.ui.get('spaces', 'manage'), + actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('catalogue', 'spaces'), + actions.ui.get('enterpriseSearch', 'all'), + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + expect(actual).toHaveProperty('global.read', [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual).toHaveProperty('space.all', [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + expect(actual).toHaveProperty('space.read', [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + }); + }); + + describe(`when license allows subfeatures, but and a licensed sub feature`, () => { + test(`should create minimal privileges, augment the primary feature privileges, and create the licensed sub-feature privileges`, () => { + const features: KibanaFeature[] = [ + new KibanaFeature({ + id: 'foo', + name: 'Foo KibanaFeature', + app: [], + category: { id: 'foo', label: 'foo' }, + privileges: { + all: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + }, + subFeatures: [ + { + name: 'subFeature1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'subFeaturePriv1', + name: 'sub feature priv 1', + includeIn: 'read', + savedObject: { + all: ['all-sub-feature-type'], + read: ['read-sub-feature-type'], + }, + ui: ['sub-feature-ui'], + }, + ], + }, + { + groupType: 'independent', + privileges: [ + { + id: 'licensedSubFeaturePriv', + name: 'licensed sub feature priv', + includeIn: 'read', + minimumLicense: 'platinum', + savedObject: { + all: ['all-licensed-sub-feature-type'], + read: ['read-licensed-sub-feature-type'], + }, + ui: ['licensed-sub-feature-ui'], + }, + ], + }, + ], + }, + ], + }), + ]; + + const mockFeaturesPlugin = { + getKibanaFeatures: jest.fn().mockReturnValue(features), + }; + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + getType: jest.fn().mockReturnValue('platinum'), + }; + const privileges = privilegesFactory(actions, mockFeaturesPlugin as any, mockLicenseService); + + const actual = privileges.get(); + expect(actual.features).toHaveProperty(`foo.subFeaturePriv1`); + expect(actual.features).toHaveProperty(`foo.licensedSubFeaturePriv`); + + expect(actual.features).toHaveProperty(`foo.all`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), + actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-licensed-sub-feature-type', 'get'), + actions.savedObject.get('all-licensed-sub-feature-type', 'find'), + actions.savedObject.get('all-licensed-sub-feature-type', 'create'), + actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-licensed-sub-feature-type', 'update'), + actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-licensed-sub-feature-type', 'delete'), + actions.savedObject.get('all-licensed-sub-feature-type', 'share_to_space'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-licensed-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-licensed-sub-feature-type', 'get'), + actions.savedObject.get('read-licensed-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + actions.ui.get('foo', 'licensed-sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty(`foo.minimal_all`, [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual.features).toHaveProperty(`foo.read`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), + actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-licensed-sub-feature-type', 'get'), + actions.savedObject.get('all-licensed-sub-feature-type', 'find'), + actions.savedObject.get('all-licensed-sub-feature-type', 'create'), + actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-licensed-sub-feature-type', 'update'), + actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-licensed-sub-feature-type', 'delete'), + actions.savedObject.get('all-licensed-sub-feature-type', 'share_to_space'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-licensed-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-licensed-sub-feature-type', 'get'), + actions.savedObject.get('read-licensed-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + actions.ui.get('foo', 'licensed-sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty(`foo.minimal_read`, [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual).toHaveProperty('global.all', [ + actions.login, + actions.version, + actions.api.get('features'), + actions.space.manage, + actions.ui.get('spaces', 'manage'), + actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('catalogue', 'spaces'), + actions.ui.get('enterpriseSearch', 'all'), + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), + actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-licensed-sub-feature-type', 'get'), + actions.savedObject.get('all-licensed-sub-feature-type', 'find'), + actions.savedObject.get('all-licensed-sub-feature-type', 'create'), + actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-licensed-sub-feature-type', 'update'), + actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-licensed-sub-feature-type', 'delete'), + actions.savedObject.get('all-licensed-sub-feature-type', 'share_to_space'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-licensed-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-licensed-sub-feature-type', 'get'), + actions.savedObject.get('read-licensed-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + actions.ui.get('foo', 'licensed-sub-feature-ui'), + ]); + expect(actual).toHaveProperty('global.read', [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), + actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-licensed-sub-feature-type', 'get'), + actions.savedObject.get('all-licensed-sub-feature-type', 'find'), + actions.savedObject.get('all-licensed-sub-feature-type', 'create'), + actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-licensed-sub-feature-type', 'update'), + actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-licensed-sub-feature-type', 'delete'), + actions.savedObject.get('all-licensed-sub-feature-type', 'share_to_space'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-licensed-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-licensed-sub-feature-type', 'get'), + actions.savedObject.get('read-licensed-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + actions.ui.get('foo', 'licensed-sub-feature-ui'), + ]); + + expect(actual).toHaveProperty('space.all', [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), + actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-licensed-sub-feature-type', 'get'), + actions.savedObject.get('all-licensed-sub-feature-type', 'find'), + actions.savedObject.get('all-licensed-sub-feature-type', 'create'), + actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-licensed-sub-feature-type', 'update'), + actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-licensed-sub-feature-type', 'delete'), + actions.savedObject.get('all-licensed-sub-feature-type', 'share_to_space'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-licensed-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-licensed-sub-feature-type', 'get'), + actions.savedObject.get('read-licensed-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + actions.ui.get('foo', 'licensed-sub-feature-ui'), + ]); + expect(actual).toHaveProperty('space.read', [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), + actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-licensed-sub-feature-type', 'get'), + actions.savedObject.get('all-licensed-sub-feature-type', 'find'), + actions.savedObject.get('all-licensed-sub-feature-type', 'create'), + actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-licensed-sub-feature-type', 'update'), + actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-licensed-sub-feature-type', 'delete'), + actions.savedObject.get('all-licensed-sub-feature-type', 'share_to_space'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-licensed-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-licensed-sub-feature-type', 'get'), + actions.savedObject.get('read-licensed-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + actions.ui.get('foo', 'licensed-sub-feature-ui'), + ]); + }); + }); }); diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.ts index 24b46222e7f35..1b2b0cd8a0a52 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.ts @@ -25,7 +25,7 @@ export interface PrivilegesService { export function privilegesFactory( actions: Actions, featuresService: FeaturesPluginSetup, - licenseService: Pick + licenseService: Pick ) { const featurePrivilegeBuilder = featurePrivilegeBuilderFactory(actions); @@ -33,6 +33,7 @@ export function privilegesFactory( get() { const features = featuresService.getKibanaFeatures(); const { allowSubFeaturePrivileges } = licenseService.getFeatures(); + const licenseType = licenseService.getType()!; const basePrivilegeFeatures = features.filter( (feature) => !feature.excludeFromBasePrivileges ); @@ -43,6 +44,7 @@ export function privilegesFactory( basePrivilegeFeatures.forEach((feature) => { for (const { privilegeId, privilege } of featurePrivilegeIterator(feature, { augmentWithSubFeaturePrivileges: true, + licenseType, predicate: (pId, featurePrivilege) => !featurePrivilege.excludeFromBasePrivileges, })) { const privilegeActions = featurePrivilegeBuilder.getActions(privilege, feature); @@ -61,6 +63,7 @@ export function privilegesFactory( featurePrivileges[feature.id] = {}; for (const featurePrivilege of featurePrivilegeIterator(feature, { augmentWithSubFeaturePrivileges: true, + licenseType, })) { featurePrivileges[feature.id][featurePrivilege.privilegeId] = [ actions.login, @@ -72,6 +75,7 @@ export function privilegesFactory( if (allowSubFeaturePrivileges && feature.subFeatures?.length > 0) { for (const featurePrivilege of featurePrivilegeIterator(feature, { augmentWithSubFeaturePrivileges: false, + licenseType, })) { featurePrivileges[feature.id][`minimal_${featurePrivilege.privilegeId}`] = [ actions.login, @@ -80,7 +84,7 @@ export function privilegesFactory( ]; } - for (const subFeaturePrivilege of subFeaturePrivilegeIterator(feature)) { + for (const subFeaturePrivilege of subFeaturePrivilegeIterator(feature, licenseType)) { featurePrivileges[feature.id][subFeaturePrivilege.id] = [ actions.login, actions.version, diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index 9b08ba8c275fd..cf9a30b0b3857 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -110,6 +110,7 @@ describe('Security Plugin', () => { }, }, "getFeatures": [Function], + "getType": [Function], "isEnabled": [Function], "isLicenseAvailable": [Function], }, diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index 4cfa9347b2b58..8e19c2e8f219d 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -18,3 +18,7 @@ export const TRUSTED_APPS_SUPPORTED_OS_TYPES: readonly string[] = ['macos', 'win export const TRUSTED_APPS_LIST_API = '/api/endpoint/trusted_apps'; export const TRUSTED_APPS_CREATE_API = '/api/endpoint/trusted_apps'; export const TRUSTED_APPS_DELETE_API = '/api/endpoint/trusted_apps/{id}'; + +export const BASE_POLICY_RESPONSE_ROUTE = `/api/endpoint/policy_response`; +export const BASE_POLICY_ROUTE = `/api/endpoint/policy`; +export const AGENT_POLICY_SUMMARY_ROUTE = `${BASE_POLICY_ROUTE}/summaries`; diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/policy.ts b/x-pack/plugins/security_solution/common/endpoint/schema/policy.ts index 35ba1266066e9..7ab8fbc8a9c8e 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/policy.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/policy.ts @@ -10,3 +10,10 @@ export const GetPolicyResponseSchema = { agentId: schema.string(), }), }; + +export const GetAgentPolicySummaryRequestSchema = { + query: schema.object({ + package_name: schema.string(), + policy_id: schema.nullable(schema.string()), + }), +}; diff --git a/x-pack/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts index 673d04c856935..66ba15431e603 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -1102,3 +1102,14 @@ export interface HostPolicyResponse { export interface GetHostPolicyResponse { policy_response: HostPolicyResponse; } + +/** + * REST API response for retrieving agent summary + */ +export interface GetAgentSummaryResponse { + summary_response: { + package: string; + policy_id?: string; + versions_count: { [key: string]: number }; + }; +} diff --git a/x-pack/plugins/security_solution/public/common/utils/default_date_settings.ts b/x-pack/plugins/security_solution/public/common/utils/default_date_settings.ts index 148143bb00bea..94545424512bc 100644 --- a/x-pack/plugins/security_solution/public/common/utils/default_date_settings.ts +++ b/x-pack/plugins/security_solution/public/common/utils/default_date_settings.ts @@ -34,8 +34,8 @@ export type DefaultIntervalSetting = DefaultInterval | null | undefined; // Defaults for if everything fails including dateMath.parse(DEFAULT_FROM) or dateMath.parse(DEFAULT_TO) // These should not really be hit unless we are in an extreme buggy state. -const DEFAULT_FROM_MOMENT = moment().subtract(24, 'hours'); -const DEFAULT_TO_MOMENT = moment(); +export const DEFAULT_FROM_MOMENT = moment().subtract(24, 'hours'); +export const DEFAULT_TO_MOMENT = moment(); /** * Retrieves timeRange settings to populate filters diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/__mocks__/index.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/__mocks__/index.ts new file mode 100644 index 0000000000000..ce4b0f09e5c95 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/__mocks__/index.ts @@ -0,0 +1,420 @@ +/* + * 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 { TimelineStatus, TimelineType } from '../../../../../common/types/timeline'; + +export const mockTimeline = { + data: { + getOneTimeline: { + savedObjectId: 'eb2781c0-1df5-11eb-8589-2f13958b79f7', + columns: [ + { + aggregatable: null, + category: null, + columnHeaderType: 'not-filtered', + description: null, + example: null, + indexes: null, + id: '@timestamp', + name: null, + searchable: null, + type: null, + __typename: 'ColumnHeaderResult', + }, + { + aggregatable: null, + category: null, + columnHeaderType: 'not-filtered', + description: null, + example: null, + indexes: null, + id: 'message', + name: null, + searchable: null, + type: null, + __typename: 'ColumnHeaderResult', + }, + { + aggregatable: null, + category: null, + columnHeaderType: 'not-filtered', + description: null, + example: null, + indexes: null, + id: 'event.category', + name: null, + searchable: null, + type: null, + __typename: 'ColumnHeaderResult', + }, + { + aggregatable: null, + category: null, + columnHeaderType: 'not-filtered', + description: null, + example: null, + indexes: null, + id: 'event.action', + name: null, + searchable: null, + type: null, + __typename: 'ColumnHeaderResult', + }, + { + aggregatable: null, + category: null, + columnHeaderType: 'not-filtered', + description: null, + example: null, + indexes: null, + id: 'host.name', + name: null, + searchable: null, + type: null, + __typename: 'ColumnHeaderResult', + }, + { + aggregatable: null, + category: null, + columnHeaderType: 'not-filtered', + description: null, + example: null, + indexes: null, + id: 'source.ip', + name: null, + searchable: null, + type: null, + __typename: 'ColumnHeaderResult', + }, + { + aggregatable: null, + category: null, + columnHeaderType: 'not-filtered', + description: null, + example: null, + indexes: null, + id: 'destination.ip', + name: null, + searchable: null, + type: null, + __typename: 'ColumnHeaderResult', + }, + { + aggregatable: null, + category: null, + columnHeaderType: 'not-filtered', + description: null, + example: null, + indexes: null, + id: 'user.name', + name: null, + searchable: null, + type: null, + __typename: 'ColumnHeaderResult', + }, + ], + dataProviders: [], + dateRange: { + start: '2020-11-01T14:30:59.935Z', + end: '2020-11-03T14:31:11.417Z', + __typename: 'DateRangePickerResult', + }, + description: '', + eventType: 'all', + eventIdToNoteIds: [], + excludedRowRendererIds: [], + favorite: [], + filters: [], + kqlMode: 'filter', + kqlQuery: { filterQuery: null, __typename: 'SerializedFilterQueryResult' }, + indexNames: [ + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'winlogbeat-*', + '.siem-signals-angelachuang-default', + ], + notes: [], + noteIds: [], + pinnedEventIds: [], + pinnedEventsSaveObject: [], + status: TimelineStatus.active, + title: 'my timeline', + timelineType: TimelineType.default, + templateTimelineId: null, + templateTimelineVersion: null, + savedQueryId: null, + sort: { + columnId: '@timestamp', + sortDirection: 'desc', + __typename: 'SortTimelineResult', + }, + created: 1604497127973, + createdBy: 'elastic', + updated: 1604500278364, + updatedBy: 'elastic', + version: 'WzQ4NSwxXQ==', + __typename: 'TimelineResult', + }, + }, + loading: false, + networkStatus: 7, + stale: false, +}; + +export const mockTemplate = { + data: { + getOneTimeline: { + savedObjectId: '0c70a200-1de0-11eb-885c-6fc13fca1850', + columns: [ + { + aggregatable: null, + category: null, + columnHeaderType: 'not-filtered', + description: null, + example: null, + indexes: null, + id: '@timestamp', + name: null, + searchable: null, + type: null, + __typename: 'ColumnHeaderResult', + }, + { + aggregatable: null, + category: null, + columnHeaderType: 'not-filtered', + description: null, + example: null, + indexes: null, + id: 'signal.rule.description', + name: null, + searchable: null, + type: null, + __typename: 'ColumnHeaderResult', + }, + { + aggregatable: null, + category: null, + columnHeaderType: 'not-filtered', + description: null, + example: null, + indexes: null, + id: 'event.action', + name: null, + searchable: null, + type: null, + __typename: 'ColumnHeaderResult', + }, + { + aggregatable: null, + category: null, + columnHeaderType: 'not-filtered', + description: null, + example: null, + indexes: null, + id: 'process.name', + name: null, + searchable: null, + type: null, + __typename: 'ColumnHeaderResult', + }, + { + aggregatable: true, + category: 'process', + columnHeaderType: 'not-filtered', + description: 'The working directory of the process.', + example: '/home/alice', + indexes: null, + id: 'process.working_directory', + name: null, + searchable: null, + type: 'string', + __typename: 'ColumnHeaderResult', + }, + { + aggregatable: true, + category: 'process', + columnHeaderType: 'not-filtered', + description: + 'Array of process arguments, starting with the absolute path to\nthe executable.\n\nMay be filtered to protect sensitive information.', + example: '["/usr/bin/ssh","-l","user","10.0.0.16"]', + indexes: null, + id: 'process.args', + name: null, + searchable: null, + type: 'string', + __typename: 'ColumnHeaderResult', + }, + { + aggregatable: null, + category: null, + columnHeaderType: 'not-filtered', + description: null, + example: null, + indexes: null, + id: 'process.pid', + name: null, + searchable: null, + type: null, + __typename: 'ColumnHeaderResult', + }, + { + aggregatable: true, + category: 'process', + columnHeaderType: 'not-filtered', + description: 'Absolute path to the process executable.', + example: '/usr/bin/ssh', + indexes: null, + id: 'process.parent.executable', + name: null, + searchable: null, + type: 'string', + __typename: 'ColumnHeaderResult', + }, + { + aggregatable: true, + category: 'process', + columnHeaderType: 'not-filtered', + description: + 'Array of process arguments.\n\nMay be filtered to protect sensitive information.', + example: '["ssh","-l","user","10.0.0.16"]', + indexes: null, + id: 'process.parent.args', + name: null, + searchable: null, + type: 'string', + __typename: 'ColumnHeaderResult', + }, + { + aggregatable: true, + category: 'process', + columnHeaderType: 'not-filtered', + description: 'Process id.', + example: '4242', + indexes: null, + id: 'process.parent.pid', + name: null, + searchable: null, + type: 'number', + __typename: 'ColumnHeaderResult', + }, + { + aggregatable: true, + category: 'user', + columnHeaderType: 'not-filtered', + description: 'Short name or login of the user.', + example: 'albert', + indexes: null, + id: 'user.name', + name: null, + searchable: null, + type: 'string', + __typename: 'ColumnHeaderResult', + }, + { + aggregatable: true, + category: 'host', + columnHeaderType: 'not-filtered', + description: + 'Name of the host.\n\nIt can contain what `hostname` returns on Unix systems, the fully qualified\ndomain name, or a name specified by the user. The sender decides which value\nto use.', + example: null, + indexes: null, + id: 'host.name', + name: null, + searchable: null, + type: 'string', + __typename: 'ColumnHeaderResult', + }, + ], + dataProviders: [ + { + id: 'timeline-1-8622010a-61fb-490d-b162-beac9c36a853', + name: '{process.name}', + enabled: true, + excluded: false, + kqlQuery: '', + type: 'template', + queryMatch: { + field: 'process.name', + displayField: null, + value: '{process.name}', + displayValue: null, + operator: ':', + __typename: 'QueryMatchResult', + }, + and: [], + __typename: 'DataProviderResult', + }, + { + id: 'timeline-1-4685da24-35c1-43f3-892d-1f926dbf5568', + name: '{event.type}', + enabled: true, + excluded: false, + kqlQuery: '', + type: 'template', + queryMatch: { + field: 'event.type', + displayField: null, + value: '{event.type}', + displayValue: null, + operator: ':*', + __typename: 'QueryMatchResult', + }, + and: [], + __typename: 'DataProviderResult', + }, + ], + dateRange: { + start: '2020-10-27T14:22:11.809Z', + end: '2020-11-03T14:22:11.809Z', + __typename: 'DateRangePickerResult', + }, + description: '', + eventType: 'all', + eventIdToNoteIds: [], + excludedRowRendererIds: [], + favorite: [], + filters: [], + kqlMode: 'filter', + kqlQuery: { + filterQuery: { + kuery: { kind: 'kuery', expression: '', __typename: 'KueryFilterQueryResult' }, + serializedQuery: '', + __typename: 'SerializedKueryQueryResult', + }, + __typename: 'SerializedFilterQueryResult', + }, + indexNames: [], + notes: [], + noteIds: [], + pinnedEventIds: [], + pinnedEventsSaveObject: [], + status: TimelineStatus.immutable, + title: 'Generic Process Timeline', + timelineType: 'template', + templateTimelineId: 'cd55e52b-7bce-4887-88e2-f1ece4c75447', + templateTimelineVersion: 1, + savedQueryId: null, + sort: { + columnId: '@timestamp', + sortDirection: 'desc', + __typename: 'SortTimelineResult', + }, + created: 1604413368243, + createdBy: 'angela', + updated: 1604413368243, + updatedBy: 'angela', + version: 'WzQwMywxXQ==', + __typename: 'TimelineResult', + }, + }, + loading: false, + networkStatus: 7, + stale: false, +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts index c89114ec77138..921527a0079e3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts @@ -3,8 +3,9 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { cloneDeep, omit } from 'lodash/fp'; +import { cloneDeep, getOr, omit } from 'lodash/fp'; import { Dispatch } from 'redux'; +import ApolloClient from 'apollo-client'; import { mockTimelineResults, @@ -30,6 +31,9 @@ import { isUntitled, omitTypenameInTimeline, dispatchUpdateTimeline, + queryTimelineById, + QueryTimelineById, + formatTimelineResultToModel, } from './helpers'; import { OpenTimelineResult, DispatchUpdateTimeline } from './types'; import { KueryFilterQueryKind } from '../../../common/store/model'; @@ -37,6 +41,10 @@ import { Note } from '../../../common/lib/note'; import moment from 'moment'; import sinon from 'sinon'; import { TimelineId, TimelineType, TimelineStatus } from '../../../../common/types/timeline'; +import { + mockTimeline as mockSelectedTimeline, + mockTemplate as mockSelectedTemplate, +} from './__mocks__'; jest.mock('../../../common/store/inputs/actions'); jest.mock('../../../common/components/url_state/normalize_time_range.ts'); @@ -49,6 +57,15 @@ jest.mock('uuid', () => { }; }); +jest.mock('../../../common/utils/default_date_settings', () => { + const actual = jest.requireActual('../../../common/utils/default_date_settings'); + return { + ...actual, + DEFAULT_FROM_MOMENT: new Date('2020-10-27T11:37:31.655Z'), + DEFAULT_TO_MOMENT: new Date('2020-10-28T11:37:31.655Z'), + }; +}); + describe('helpers', () => { let mockResults: OpenTimelineResult[]; @@ -903,6 +920,395 @@ describe('helpers', () => { id: 'savedObject-1', }); }); + + test('should override timerange if given an Elastic prebuilt template', () => { + const timeline = { + savedObjectId: 'savedObject-1', + title: 'Awesome Timeline', + version: '1', + status: TimelineStatus.immutable, + timelineType: TimelineType.template, + }; + + const newTimeline = defaultTimelineToTimelineModel(timeline, false, TimelineType.template); + expect(newTimeline).toEqual({ + columns: [ + { + columnHeaderType: 'not-filtered', + id: '@timestamp', + width: 190, + }, + { + columnHeaderType: 'not-filtered', + id: 'message', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'event.category', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'event.action', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'host.name', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'source.ip', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'destination.ip', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'user.name', + width: 180, + }, + ], + dataProviders: [], + dateRange: { end: '2020-10-28T11:37:31.655Z', start: '2020-10-27T11:37:31.655Z' }, + description: '', + deletedEventIds: [], + eventIdToNoteIds: {}, + eventType: 'all', + excludedRowRendererIds: [], + filters: [], + highlightedDropAndProviderId: '', + historyIds: [], + id: 'savedObject-1', + indexNames: [], + isFavorite: false, + isLive: false, + isSelectAllChecked: false, + isLoading: false, + isSaving: false, + itemsPerPage: 25, + itemsPerPageOptions: [10, 25, 50, 100], + kqlMode: 'filter', + kqlQuery: { + filterQuery: null, + filterQueryDraft: null, + }, + loadingEventIds: [], + noteIds: [], + pinnedEventIds: {}, + pinnedEventsSaveObject: {}, + savedObjectId: 'savedObject-1', + selectedEventIds: {}, + show: false, + showCheckboxes: false, + sort: { + columnId: '@timestamp', + sortDirection: 'desc', + }, + status: TimelineStatus.immutable, + title: 'Awesome Timeline', + timelineType: TimelineType.template, + templateTimelineId: null, + templateTimelineVersion: null, + version: '1', + width: 1100, + }); + }); + + test('should not override timerange if given a custom template or timeline', () => { + const timeline = { + savedObjectId: 'savedObject-1', + title: 'Awesome Timeline', + version: '1', + status: TimelineStatus.active, + timelineType: TimelineType.default, + }; + + const newTimeline = defaultTimelineToTimelineModel(timeline, false, TimelineType.default); + expect(newTimeline).toEqual({ + columns: [ + { + columnHeaderType: 'not-filtered', + id: '@timestamp', + width: 190, + }, + { + columnHeaderType: 'not-filtered', + id: 'message', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'event.category', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'event.action', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'host.name', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'source.ip', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'destination.ip', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'user.name', + width: 180, + }, + ], + dataProviders: [], + dateRange: { end: '2020-07-08T08:20:18.966Z', start: '2020-07-07T08:20:18.966Z' }, + description: '', + deletedEventIds: [], + eventIdToNoteIds: {}, + eventType: 'all', + excludedRowRendererIds: [], + filters: [], + highlightedDropAndProviderId: '', + historyIds: [], + id: 'savedObject-1', + indexNames: [], + isFavorite: false, + isLive: false, + isSelectAllChecked: false, + isLoading: false, + isSaving: false, + itemsPerPage: 25, + itemsPerPageOptions: [10, 25, 50, 100], + kqlMode: 'filter', + kqlQuery: { + filterQuery: null, + filterQueryDraft: null, + }, + loadingEventIds: [], + noteIds: [], + pinnedEventIds: {}, + pinnedEventsSaveObject: {}, + savedObjectId: 'savedObject-1', + selectedEventIds: {}, + show: false, + showCheckboxes: false, + sort: { + columnId: '@timestamp', + sortDirection: 'desc', + }, + status: TimelineStatus.active, + title: 'Awesome Timeline', + timelineType: TimelineType.default, + templateTimelineId: null, + templateTimelineVersion: null, + version: '1', + width: 1100, + }); + }); + }); + + describe('queryTimelineById', () => { + describe('open a timeline', () => { + const updateIsLoading = jest.fn(); + const selectedTimeline = { + ...mockSelectedTimeline, + }; + const apolloClient = { + query: (jest.fn().mockResolvedValue(selectedTimeline) as unknown) as ApolloClient<{}>, + }; + const onOpenTimeline = jest.fn(); + const args = { + apolloClient, + duplicate: false, + graphEventId: '', + timelineId: '', + timelineType: TimelineType.default, + onOpenTimeline, + openTimeline: true, + updateIsLoading, + updateTimeline: jest.fn(), + }; + + beforeAll(async () => { + await queryTimelineById<{}>((args as unknown) as QueryTimelineById<{}>); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + test('dispatch updateIsLoading to true', () => { + expect(updateIsLoading.mock.calls[0][0]).toEqual({ + id: TimelineId.active, + isLoading: true, + }); + }); + + test('get timeline by Id', () => { + expect(apolloClient.query).toHaveBeenCalled(); + }); + + test('Do not override daterange if TimelineStatus is active', () => { + const { timeline } = formatTimelineResultToModel( + omitTypenameInTimeline(getOr({}, 'data.getOneTimeline', selectedTimeline)), + args.duplicate, + args.timelineType + ); + expect(onOpenTimeline).toBeCalledWith({ + ...timeline, + }); + }); + + test('dispatch updateIsLoading to false', () => { + expect(updateIsLoading.mock.calls[1][0]).toEqual({ + id: TimelineId.active, + isLoading: false, + }); + }); + }); + + describe('update a timeline', () => { + const updateIsLoading = jest.fn(); + const updateTimeline = jest.fn(); + const selectedTimeline = { ...mockSelectedTimeline }; + const apolloClient = { + query: (jest.fn().mockResolvedValue(selectedTimeline) as unknown) as ApolloClient<{}>, + }; + const args = { + apolloClient, + duplicate: false, + graphEventId: '', + timelineId: '', + timelineType: TimelineType.default, + openTimeline: true, + updateIsLoading, + updateTimeline, + }; + + beforeAll(async () => { + await queryTimelineById<{}>((args as unknown) as QueryTimelineById<{}>); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + test('dispatch updateIsLoading to true', () => { + expect(updateIsLoading.mock.calls[0][0]).toEqual({ + id: TimelineId.active, + isLoading: true, + }); + }); + + test('get timeline by Id', () => { + expect(apolloClient.query).toHaveBeenCalled(); + }); + + test('should not override daterange if TimelineStatus is active', () => { + const { timeline } = formatTimelineResultToModel( + omitTypenameInTimeline(getOr({}, 'data.getOneTimeline', selectedTimeline)), + args.duplicate, + args.timelineType + ); + expect(updateTimeline).toBeCalledWith({ + timeline: { + ...timeline, + graphEventId: '', + show: true, + dateRange: { + start: '2020-07-07T08:20:18.966Z', + end: '2020-07-08T08:20:18.966Z', + }, + }, + duplicate: false, + from: '2020-07-07T08:20:18.966Z', + to: '2020-07-08T08:20:18.966Z', + notes: [], + id: TimelineId.active, + }); + }); + + test('dispatch updateIsLoading to false', () => { + expect(updateIsLoading.mock.calls[1][0]).toEqual({ + id: TimelineId.active, + isLoading: false, + }); + }); + }); + + describe('open an immutable template', () => { + const updateIsLoading = jest.fn(); + const template = { ...mockSelectedTemplate }; + const apolloClient = { + query: (jest.fn().mockResolvedValue(template) as unknown) as ApolloClient<{}>, + }; + const onOpenTimeline = jest.fn(); + const args = { + apolloClient, + duplicate: false, + graphEventId: '', + timelineId: '', + timelineType: TimelineType.template, + onOpenTimeline, + openTimeline: true, + updateIsLoading, + updateTimeline: jest.fn(), + }; + + beforeAll(async () => { + await queryTimelineById<{}>((args as unknown) as QueryTimelineById<{}>); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + test('dispatch updateIsLoading to true', () => { + expect(updateIsLoading.mock.calls[0][0]).toEqual({ + id: TimelineId.active, + isLoading: true, + }); + }); + + test('get timeline by Id', () => { + expect(apolloClient.query).toHaveBeenCalled(); + }); + + test('override daterange if TimelineStatus is immutable', () => { + const { timeline } = formatTimelineResultToModel( + omitTypenameInTimeline(getOr({}, 'data.getOneTimeline', template)), + args.duplicate, + args.timelineType + ); + expect(onOpenTimeline).toBeCalledWith({ + ...timeline, + dateRange: { + end: '2020-10-28T11:37:31.655Z', + start: '2020-10-27T11:37:31.655Z', + }, + }); + }); + + test('dispatch updateIsLoading to false', () => { + expect(updateIsLoading.mock.calls[1][0]).toEqual({ + id: TimelineId.active, + isLoading: false, + }); + }); + }); }); describe('omitTypenameInTimeline', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts index 4c3be81a4992a..a0090baeb9923 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts @@ -33,7 +33,10 @@ import { addNotes as dispatchAddNotes, updateNote as dispatchUpdateNote, } from '../../../common/store/app/actions'; -import { setTimelineRangeDatePicker as dispatchSetTimelineRangeDatePicker } from '../../../common/store/inputs/actions'; +import { + setTimelineRangeDatePicker as dispatchSetTimelineRangeDatePicker, + setRelativeRangeDatePicker as dispatchSetRelativeRangeDatePicker, +} from '../../../common/store/inputs/actions'; import { setKqlFilterQueryDraft as dispatchSetKqlFilterQueryDraft, applyKqlFilterQuery as dispatchApplyKqlFilterQuery, @@ -58,6 +61,10 @@ import { IS_OPERATOR } from '../timeline/data_providers/data_provider'; import { normalizeTimeRange } from '../../../common/components/url_state/normalize_time_range'; import { sourcererActions } from '../../../common/store/sourcerer'; import { SourcererScopeName } from '../../../common/store/sourcerer/model'; +import { + DEFAULT_FROM_MOMENT, + DEFAULT_TO_MOMENT, +} from '../../../common/utils/default_date_settings'; export const OPEN_TIMELINE_CLASS_NAME = 'open-timeline'; @@ -252,6 +259,14 @@ export const defaultTimelineToTimelineModel = ( const timelineEntries = { ...timeline, columns: timeline.columns != null ? timeline.columns.map(setTimelineColumn) : defaultHeaders, + dateRange: + timeline.status === TimelineStatus.immutable && + timeline.timelineType === TimelineType.template + ? { + start: DEFAULT_FROM_MOMENT.toISOString(), + end: DEFAULT_TO_MOMENT.toISOString(), + } + : timeline.dateRange, dataProviders: getDataProviders(duplicate, timeline.dataProviders, timelineType), eventIdToNoteIds: setEventIdToNoteIds(duplicate, timeline.eventIdToNoteIds), filters: timeline.filters != null ? timeline.filters.map(setTimelineFilters) : [], @@ -340,6 +355,7 @@ export const queryTimelineById = ({ duplicate, timelineType ); + if (onOpenTimeline != null) { onOpenTimeline(timeline); } else if (updateTimeline) { @@ -356,6 +372,7 @@ export const queryTimelineById = ({ ...timeline, graphEventId, show: openTimeline, + dateRange: { start: from, end: to }, }, to, })(); @@ -384,7 +401,22 @@ export const dispatchUpdateTimeline = (dispatch: Dispatch): DispatchUpdateTimeli eventType: timeline.eventType, }) ); - dispatch(dispatchSetTimelineRangeDatePicker({ from, to })); + if ( + timeline.status === TimelineStatus.immutable && + timeline.timelineType === TimelineType.template + ) { + dispatch( + dispatchSetRelativeRangeDatePicker({ + id: 'timeline', + fromStr: 'now-24h', + toStr: 'now', + from: DEFAULT_FROM_MOMENT.toISOString(), + to: DEFAULT_TO_MOMENT.toISOString(), + }) + ); + } else { + dispatch(dispatchSetTimelineRangeDatePicker({ from, to })); + } dispatch(dispatchAddTimeline({ id, timeline, savedTimeline: duplicate })); if ( timeline.kqlQuery != null && diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts index d4e807b4a9a07..9a0bf5ec4a940 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts @@ -26,6 +26,7 @@ import { TimelineTypeLiteral, TimelineType, RowRendererId, + TimelineStatus, TimelineId, } from '../../../../common/types/timeline'; import { normalizeTimeRange } from '../../../common/components/url_state/normalize_time_range'; @@ -33,6 +34,10 @@ import { normalizeTimeRange } from '../../../common/components/url_state/normali import { timelineDefaults } from './defaults'; import { ColumnHeaderOptions, KqlMode, TimelineModel } from './model'; import { TimelineById } from './types'; +import { + DEFAULT_FROM_MOMENT, + DEFAULT_TO_MOMENT, +} from '../../../common/utils/default_date_settings'; import { activeTimeline } from '../../containers/active_timeline_context'; export const isNotNull = (value: T | null): value is T => value !== null; @@ -144,6 +149,14 @@ export const addTimelineToStore = ({ [id]: { ...timeline, isLoading: timelineById[id].isLoading, + dateRange: + timeline.status === TimelineStatus.immutable && + timeline.timelineType === TimelineType.template + ? { + start: DEFAULT_FROM_MOMENT.toISOString(), + end: DEFAULT_TO_MOMENT.toISOString(), + } + : timeline.dateRange, }, }; }; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts index c2f43625ab464..7bd86cd7e2452 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts @@ -48,6 +48,14 @@ import { timelineDefaults } from './defaults'; import { TimelineById } from './types'; jest.mock('../../../common/components/url_state/normalize_time_range.ts'); +jest.mock('../../../common/utils/default_date_settings', () => { + const actual = jest.requireActual('../../../common/utils/default_date_settings'); + return { + ...actual, + DEFAULT_FROM_MOMENT: new Date('2020-10-27T11:37:31.655Z'), + DEFAULT_TO_MOMENT: new Date('2020-10-28T11:37:31.655Z'), + }; +}); const basicDataProvider: DataProvider = { and: [], @@ -141,6 +149,31 @@ describe('Timeline', () => { }, }); }); + + test('should override timerange if adding an immutable template', () => { + const update = addTimelineToStore({ + id: 'foo', + timeline: { + ...basicTimeline, + status: TimelineStatus.immutable, + timelineType: TimelineType.template, + }, + timelineById: timelineByIdMock, + }); + + expect(update).toEqual({ + foo: { + ...basicTimeline, + status: TimelineStatus.immutable, + timelineType: TimelineType.template, + dateRange: { + start: '2020-10-27T11:37:31.655Z', + end: '2020-10-28T11:37:31.655Z', + }, + show: true, + }, + }); + }); }); describe('#addNewTimeline', () => { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.test.ts index 7dddc357fe53d..009ce043db85e 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.test.ts @@ -5,10 +5,11 @@ */ import { EndpointAppContextService } from '../../endpoint_app_context_services'; import { + createMockAgentService, createMockEndpointAppContextServiceStartContract, createRouteHandlerContext, } from '../../mocks'; -import { getHostPolicyResponseHandler } from './handlers'; +import { getHostPolicyResponseHandler, getAgentPolicySummaryHandler } from './handlers'; import { ILegacyScopedClusterClient, KibanaResponseFactory, @@ -24,6 +25,8 @@ import { SearchResponse } from 'elasticsearch'; import { GetHostPolicyResponse, HostPolicyResponse } from '../../../../common/endpoint/types'; import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data'; import { createMockConfig } from '../../../lib/detection_engine/routes/__mocks__'; +import { Agent } from '../../../../../fleet/common/types/models'; +import { AgentService } from '../../../../../fleet/server/services'; describe('test policy response handler', () => { let endpointAppContextService: EndpointAppContextService; @@ -31,64 +34,198 @@ describe('test policy response handler', () => { let mockSavedObjectClient: jest.Mocked; let mockResponse: jest.Mocked; - beforeEach(() => { - mockScopedClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockSavedObjectClient = savedObjectsClientMock.create(); - mockResponse = httpServerMock.createResponseFactory(); - endpointAppContextService = new EndpointAppContextService(); - endpointAppContextService.start(createMockEndpointAppContextServiceStartContract()); - }); + describe('test policy response handler', () => { + beforeEach(() => { + mockScopedClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); + mockSavedObjectClient = savedObjectsClientMock.create(); + mockResponse = httpServerMock.createResponseFactory(); + endpointAppContextService = new EndpointAppContextService(); + endpointAppContextService.start(createMockEndpointAppContextServiceStartContract()); + }); - afterEach(() => endpointAppContextService.stop()); + afterEach(() => endpointAppContextService.stop()); - it('should return the latest policy response for a host', async () => { - const response = createSearchResponse(new EndpointDocGenerator().generatePolicyResponse()); - const hostPolicyResponseHandler = getHostPolicyResponseHandler({ - logFactory: loggingSystemMock.create(), - service: endpointAppContextService, - config: () => Promise.resolve(createMockConfig()), - }); + it('should return the latest policy response for a host', async () => { + const response = createSearchResponse(new EndpointDocGenerator().generatePolicyResponse()); + const hostPolicyResponseHandler = getHostPolicyResponseHandler({ + logFactory: loggingSystemMock.create(), + service: endpointAppContextService, + config: () => Promise.resolve(createMockConfig()), + }); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); - const mockRequest = httpServerMock.createKibanaRequest({ - params: { agentId: 'id' }, + mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); + const mockRequest = httpServerMock.createKibanaRequest({ + params: { agentId: 'id' }, + }); + + await hostPolicyResponseHandler( + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), + mockRequest, + mockResponse + ); + + expect(mockResponse.ok).toBeCalled(); + const result = mockResponse.ok.mock.calls[0][0]?.body as GetHostPolicyResponse; + expect(result.policy_response.agent.id).toEqual(response.hits.hits[0]._source.agent.id); }); - await hostPolicyResponseHandler( - createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), - mockRequest, - mockResponse - ); + it('should return not found when there is no response policy for host', async () => { + const hostPolicyResponseHandler = getHostPolicyResponseHandler({ + logFactory: loggingSystemMock.create(), + service: endpointAppContextService, + config: () => Promise.resolve(createMockConfig()), + }); + + mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => + Promise.resolve(createSearchResponse()) + ); - expect(mockResponse.ok).toBeCalled(); - const result = mockResponse.ok.mock.calls[0][0]?.body as GetHostPolicyResponse; - expect(result.policy_response.agent.id).toEqual(response.hits.hits[0]._source.agent.id); + const mockRequest = httpServerMock.createKibanaRequest({ + params: { agentId: 'id' }, + }); + + await hostPolicyResponseHandler( + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), + mockRequest, + mockResponse + ); + + expect(mockResponse.notFound).toBeCalled(); + const message = mockResponse.notFound.mock.calls[0][0]?.body; + expect(message).toEqual('Policy Response Not Found'); + }); }); - it('should return not found when there is no response policy for host', async () => { - const hostPolicyResponseHandler = getHostPolicyResponseHandler({ - logFactory: loggingSystemMock.create(), - service: endpointAppContextService, - config: () => Promise.resolve(createMockConfig()), + describe('test agent policy summary handler', () => { + let mockAgentService: jest.Mocked; + + let agentListResult: { + agents: Agent[]; + total: number; + page: number; + perPage: number; + }; + + let emptyAgentListResult: { + agents: Agent[]; + total: number; + page: number; + perPage: number; + }; + + beforeEach(() => { + mockScopedClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); + mockSavedObjectClient = savedObjectsClientMock.create(); + mockResponse = httpServerMock.createResponseFactory(); + endpointAppContextService = new EndpointAppContextService(); + mockAgentService = createMockAgentService(); + emptyAgentListResult = { + agents: [], + total: 2, + page: 1, + perPage: 1, + }; + + agentListResult = { + agents: [ + ({ + local_metadata: { + elastic: { + agent: { + version: '8.0.0', + }, + }, + }, + } as unknown) as Agent, + ({ + local_metadata: { + elastic: { + agent: { + version: '8.0.0', + }, + }, + }, + } as unknown) as Agent, + ({ + local_metadata: { + elastic: { + agent: { + version: '8.1.0', + }, + }, + }, + } as unknown) as Agent, + ], + total: 2, + page: 1, + perPage: 1, + }; + endpointAppContextService.start({ + ...createMockEndpointAppContextServiceStartContract(), + ...{ agentService: mockAgentService }, + }); }); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => - Promise.resolve(createSearchResponse()) - ); + afterEach(() => endpointAppContextService.stop()); + + it('should return the summary of all the agent with the given policy name', async () => { + mockAgentService.listAgents + .mockImplementationOnce(() => Promise.resolve(agentListResult)) + .mockImplementationOnce(() => Promise.resolve(emptyAgentListResult)); - const mockRequest = httpServerMock.createKibanaRequest({ - params: { agentId: 'id' }, + const policySummarysHandler = getAgentPolicySummaryHandler({ + logFactory: loggingSystemMock.create(), + service: endpointAppContextService, + config: () => Promise.resolve(createMockConfig()), + }); + + const mockRequest = httpServerMock.createKibanaRequest({ + query: { policy_id: '41a1b470-221b-11eb-8fba-fb9c0d46ace3', package_name: 'endpoint' }, + }); + + await policySummarysHandler( + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), + mockRequest, + mockResponse + ); + expect(mockResponse.ok).toBeCalled(); + expect(mockResponse.ok.mock.calls[0][0]?.body).toEqual({ + summary_response: { + policy_id: '41a1b470-221b-11eb-8fba-fb9c0d46ace3', + package: 'endpoint', + versions_count: { '8.0.0': 2, '8.1.0': 1 }, + }, + }); }); - await hostPolicyResponseHandler( - createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), - mockRequest, - mockResponse - ); + it('should return the agent summary', async () => { + mockAgentService.listAgents + .mockImplementationOnce(() => Promise.resolve(agentListResult)) + .mockImplementationOnce(() => Promise.resolve(emptyAgentListResult)); + + const agentPolicySummaryHandler = getAgentPolicySummaryHandler({ + logFactory: loggingSystemMock.create(), + service: endpointAppContextService, + config: () => Promise.resolve(createMockConfig()), + }); - expect(mockResponse.notFound).toBeCalled(); - const message = mockResponse.notFound.mock.calls[0][0]?.body; - expect(message).toEqual('Policy Response Not Found'); + const mockRequest = httpServerMock.createKibanaRequest({ + query: { package_name: 'endpoint' }, + }); + + await agentPolicySummaryHandler( + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), + mockRequest, + mockResponse + ); + expect(mockResponse.ok).toBeCalled(); + expect(mockResponse.ok.mock.calls[0][0]?.body).toEqual({ + summary_response: { + package: 'endpoint', + versions_count: { '8.0.0': 2, '8.1.0': 1 }, + }, + }); + }); }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.ts b/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.ts index f3a7b08a4cd44..728e3279c52a4 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.ts @@ -6,9 +6,13 @@ import { RequestHandler } from 'kibana/server'; import { TypeOf } from '@kbn/config-schema'; import { policyIndexPattern } from '../../../../common/endpoint/constants'; -import { GetPolicyResponseSchema } from '../../../../common/endpoint/schema/policy'; +import { + GetPolicyResponseSchema, + GetAgentPolicySummaryRequestSchema, +} from '../../../../common/endpoint/schema/policy'; import { EndpointAppContext } from '../../types'; -import { getPolicyResponseByAgentId } from './service'; +import { getAgentPolicySummary, getPolicyResponseByAgentId } from './service'; +import { GetAgentSummaryResponse } from '../../../../common/endpoint/types'; export const getHostPolicyResponseHandler = function ( endpointAppContext: EndpointAppContext @@ -31,3 +35,35 @@ export const getHostPolicyResponseHandler = function ( } }; }; + +export const getAgentPolicySummaryHandler = function ( + endpointAppContext: EndpointAppContext +): RequestHandler, undefined> { + return async (context, request, response) => { + try { + const result = await getAgentPolicySummary( + endpointAppContext, + context.core.savedObjects.client, + request.query.package_name, + request.query?.policy_id || undefined + ); + const responseBody = { + package: request.query.package_name, + versions_count: { ...result }, + }; + + const body: GetAgentSummaryResponse = { + summary_response: request.query?.policy_id + ? { ...responseBody, ...{ policy_id: request.query?.policy_id } } + : responseBody, + }; + + return response.ok({ + body, + }); + } catch (err) { + endpointAppContext.logFactory.get('metadata').error(JSON.stringify(err, null, 2)); + return response.internalError({ body: err }); + } + }; +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/policy/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/policy/index.ts index 5993b0b0e752e..a924095b95be1 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/policy/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/policy/index.ts @@ -6,10 +6,15 @@ import { IRouter } from 'kibana/server'; import { EndpointAppContext } from '../../types'; -import { GetPolicyResponseSchema } from '../../../../common/endpoint/schema/policy'; -import { getHostPolicyResponseHandler } from './handlers'; - -export const BASE_POLICY_RESPONSE_ROUTE = `/api/endpoint/policy_response`; +import { + GetPolicyResponseSchema, + GetAgentPolicySummaryRequestSchema, +} from '../../../../common/endpoint/schema/policy'; +import { getHostPolicyResponseHandler, getAgentPolicySummaryHandler } from './handlers'; +import { + AGENT_POLICY_SUMMARY_ROUTE, + BASE_POLICY_RESPONSE_ROUTE, +} from '../../../../common/endpoint/constants'; export const INITIAL_POLICY_ID = '00000000-0000-0000-0000-000000000000'; @@ -22,4 +27,13 @@ export function registerPolicyRoutes(router: IRouter, endpointAppContext: Endpoi }, getHostPolicyResponseHandler(endpointAppContext) ); + + router.get( + { + path: AGENT_POLICY_SUMMARY_ROUTE, + validate: GetAgentPolicySummaryRequestSchema, + options: { authRequired: true }, + }, + getAgentPolicySummaryHandler(endpointAppContext) + ); } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/policy/service.ts b/x-pack/plugins/security_solution/server/endpoint/routes/policy/service.ts index 0019c97a6cced..e670ca6e20cb2 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/policy/service.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/policy/service.ts @@ -5,9 +5,12 @@ */ import { SearchResponse } from 'elasticsearch'; -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { ILegacyScopedClusterClient, SavedObjectsClientContract } from 'kibana/server'; import { GetHostPolicyResponse, HostPolicyResponse } from '../../../../common/endpoint/types'; import { INITIAL_POLICY_ID } from './index'; +import { Agent } from '../../../../../fleet/common/types/models'; +import { EndpointAppContext } from '../../types'; +import { AGENT_SAVED_OBJECT_TYPE } from '../../../../../fleet/common/constants'; export function getESQueryPolicyResponseByAgentID(agentID: string, index: string) { return { @@ -57,3 +60,70 @@ export async function getPolicyResponseByAgentId( policy_response: response.hits.hits[0]._source, }; } + +const transformAgentVersionMap = (versionMap: Map): { [key: string]: number } => { + const data: { [key: string]: number } = {}; + versionMap.forEach((value, key) => { + data[key] = value; + }); + return data; +}; + +export async function getAgentPolicySummary( + endpointAppContext: EndpointAppContext, + soClient: SavedObjectsClientContract, + packageName: string, + policyId?: string, + pageSize: number = 1000 +): Promise<{ [key: string]: number }> { + const agentQuery = `${AGENT_SAVED_OBJECT_TYPE}.packages:"${packageName}"`; + if (policyId) { + return transformAgentVersionMap( + await agentVersionsMap( + endpointAppContext, + soClient, + `${agentQuery} AND ${AGENT_SAVED_OBJECT_TYPE}.policy_id:${policyId}`, + pageSize + ) + ); + } + + return transformAgentVersionMap( + await agentVersionsMap(endpointAppContext, soClient, agentQuery, pageSize) + ); +} + +export async function agentVersionsMap( + endpointAppContext: EndpointAppContext, + soClient: SavedObjectsClientContract, + kqlQuery: string, + pageSize: number = 1000 +): Promise> { + const searchOptions = (pageNum: number) => { + return { + page: pageNum, + perPage: pageSize, + showInactive: false, + kuery: kqlQuery, + }; + }; + + let page = 1; + const result: Map = new Map(); + let hasMore = true; + while (hasMore) { + const queryResult = await endpointAppContext.service + .getAgentService()! + .listAgents(soClient, searchOptions(page++)); + queryResult.agents.forEach((agent: Agent) => { + const agentVersion = agent.local_metadata?.elastic?.agent?.version; + if (result.has(agentVersion)) { + result.set(agentVersion, result.get(agentVersion)! + 1); + } else { + result.set(agentVersion, 1); + } + }); + hasMore = queryResult.agents.length > 0; + } + return result; +} diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/create_timelines.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/create_timelines.ts new file mode 100644 index 0000000000000..e8242d9691032 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/create_timelines.ts @@ -0,0 +1,217 @@ +/* + * 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 mockTemplate = { + columns: [ + { + columnHeaderType: 'not-filtered', + indexes: null, + id: '@timestamp', + name: null, + searchable: null, + }, + { + columnHeaderType: 'not-filtered', + indexes: null, + id: 'signal.rule.description', + name: null, + searchable: null, + }, + { + columnHeaderType: 'not-filtered', + indexes: null, + id: 'event.action', + name: null, + searchable: null, + }, + { + columnHeaderType: 'not-filtered', + indexes: null, + id: 'process.name', + name: null, + searchable: null, + }, + { + aggregatable: true, + category: 'process', + columnHeaderType: 'not-filtered', + description: 'The working directory of the process.', + example: '/home/alice', + indexes: null, + id: 'process.working_directory', + name: null, + searchable: null, + type: 'string', + }, + { + aggregatable: true, + category: 'process', + columnHeaderType: 'not-filtered', + description: + 'Array of process arguments, starting with the absolute path to\nthe executable.\n\nMay be filtered to protect sensitive information.', + example: '["/usr/bin/ssh","-l","user","10.0.0.16"]', + indexes: null, + id: 'process.args', + name: null, + searchable: null, + type: 'string', + }, + { + columnHeaderType: 'not-filtered', + indexes: null, + id: 'process.pid', + name: null, + searchable: null, + }, + { + aggregatable: true, + category: 'process', + columnHeaderType: 'not-filtered', + description: 'Absolute path to the process executable.', + example: '/usr/bin/ssh', + indexes: null, + id: 'process.parent.executable', + name: null, + searchable: null, + type: 'string', + }, + { + aggregatable: true, + category: 'process', + columnHeaderType: 'not-filtered', + description: + 'Array of process arguments.\n\nMay be filtered to protect sensitive information.', + example: '["ssh","-l","user","10.0.0.16"]', + indexes: null, + id: 'process.parent.args', + name: null, + searchable: null, + type: 'string', + }, + { + aggregatable: true, + category: 'process', + columnHeaderType: 'not-filtered', + description: 'Process id.', + example: '4242', + indexes: null, + id: 'process.parent.pid', + name: null, + searchable: null, + type: 'number', + }, + { + aggregatable: true, + category: 'user', + columnHeaderType: 'not-filtered', + description: 'Short name or login of the user.', + example: 'albert', + indexes: null, + id: 'user.name', + name: null, + searchable: null, + type: 'string', + }, + { + aggregatable: true, + category: 'host', + columnHeaderType: 'not-filtered', + description: + 'Name of the host.\n\nIt can contain what `hostname` returns on Unix systems, the fully qualified\ndomain name, or a name specified by the user. The sender decides which value\nto use.', + indexes: null, + id: 'host.name', + name: null, + searchable: null, + type: 'string', + }, + ], + dataProviders: [ + { + id: 'timeline-1-8622010a-61fb-490d-b162-beac9c36a853', + name: '{process.name}', + enabled: true, + excluded: false, + kqlQuery: '', + type: 'template', + queryMatch: { + field: 'process.name', + displayField: null, + value: '{process.name}', + displayValue: null, + operator: ':', + }, + and: [], + }, + { + id: 'timeline-1-4685da24-35c1-43f3-892d-1f926dbf5568', + name: '{event.type}', + enabled: true, + excluded: false, + kqlQuery: '', + type: 'template', + queryMatch: { + field: 'event.type', + displayField: null, + value: '{event.type}', + displayValue: null, + operator: ':*', + }, + and: [], + }, + ], + description: '', + eventType: 'all', + excludedRowRendererIds: [], + filters: [], + kqlMode: 'filter', + kqlQuery: { filterQuery: { kuery: { kind: 'kuery', expression: '' }, serializedQuery: '' } }, + indexNames: [], + title: 'Generic Process Timeline - Duplicate - Duplicate', + timelineType: 'template', + templateTimelineVersion: null, + templateTimelineId: null, + dateRange: { start: '2020-10-01T11:37:31.655Z', end: '2020-10-02T11:37:31.655Z' }, + savedQueryId: null, + sort: { columnId: '@timestamp', sortDirection: 'desc' }, + status: 'active', +}; + +export const mockTimeline = { + columns: [ + { columnHeaderType: 'not-filtered', id: '@timestamp' }, + { columnHeaderType: 'not-filtered', id: 'message' }, + { columnHeaderType: 'not-filtered', id: 'event.category' }, + { columnHeaderType: 'not-filtered', id: 'event.action' }, + { columnHeaderType: 'not-filtered', id: 'host.name' }, + { columnHeaderType: 'not-filtered', id: 'source.ip' }, + { columnHeaderType: 'not-filtered', id: 'destination.ip' }, + { columnHeaderType: 'not-filtered', id: 'user.name' }, + ], + dataProviders: [], + description: '', + eventType: 'all', + excludedRowRendererIds: [], + filters: [], + kqlMode: 'filter', + kqlQuery: { filterQuery: null }, + indexNames: [ + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'winlogbeat-*', + '.siem-signals-angelachuang-default', + ], + title: 'my timeline', + timelineType: 'default', + templateTimelineVersion: null, + templateTimelineId: null, + dateRange: { start: '2020-11-03T13:34:40.339Z', end: '2020-11-04T13:34:40.339Z' }, + savedQueryId: null, + sort: { columnId: '@timestamp', sortDirection: 'desc' }, + status: 'draft', +}; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/create_timelines.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/create_timelines.test.ts new file mode 100644 index 0000000000000..933e71cc10255 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/create_timelines.test.ts @@ -0,0 +1,166 @@ +/* + * 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 * as module from './create_timelines'; +import { persistTimeline } from '../../saved_object'; +import { persistPinnedEventOnTimeline } from '../../../pinned_event/saved_object'; +import { persistNote, getNote } from '../../../note/saved_object'; +import { FrameworkRequest } from '../../../framework'; +import { SavedTimeline } from '../../../../../common/types/timeline'; +import { mockTemplate, mockTimeline } from '../__mocks__/create_timelines'; + +const frameworkRequest = {} as FrameworkRequest; +const template = { ...mockTemplate } as SavedTimeline; +const timeline = { ...mockTimeline } as SavedTimeline; +const timelineSavedObjectId = null; +const timelineVersion = null; +const pinnedEventIds = ['123']; +const notes = [ + { noteId: 'abc', note: 'new note', timelineId: '', created: 1603885051655, createdBy: 'elastic' }, +]; +const existingNoteIds = undefined; +const isImmutable = true; +const newTimelineSavedObjectId = 'eb2781c0-1df5-11eb-8589-2f13958b79f7'; + +jest.mock('moment', () => { + const mockMoment = { + toISOString: jest + .fn() + .mockReturnValueOnce('2020-11-03T11:37:31.655Z') + .mockReturnValue('2020-11-04T11:37:31.655Z'), + subtract: jest.fn(), + }; + mockMoment.subtract.mockReturnValue(mockMoment); + return jest.fn().mockReturnValue(mockMoment); +}); + +jest.mock('../../saved_object', () => ({ + persistTimeline: jest.fn().mockResolvedValue({ + timeline: { + savedObjectId: 'eb2781c0-1df5-11eb-8589-2f13958b79f7', + version: 'xJs23==', + }, + }), +})); + +jest.mock('../../../pinned_event/saved_object', () => ({ + persistPinnedEventOnTimeline: jest.fn(), +})); + +jest.mock('../../../note/saved_object', () => ({ + getNote: jest.fn(), + persistNote: jest.fn(), +})); + +describe('createTimelines', () => { + describe('create timelines', () => { + beforeAll(async () => { + await module.createTimelines({ + frameworkRequest, + timeline, + timelineSavedObjectId, + timelineVersion, + pinnedEventIds, + notes, + existingNoteIds, + isImmutable: false, + }); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + test('respect input timerange - start', () => { + expect((persistTimeline as jest.Mock).mock.calls[0][3].dateRange.start).toEqual( + '2020-11-03T13:34:40.339Z' + ); + }); + + test('respect input timerange - end', () => { + expect((persistTimeline as jest.Mock).mock.calls[0][3].dateRange.end).toEqual( + '2020-11-04T13:34:40.339Z' + ); + }); + + test('savePinnedEvents', () => { + expect((persistPinnedEventOnTimeline as jest.Mock).mock.calls[0][2]).toEqual('123'); + }); + + test('saveNotes', () => { + expect((persistNote as jest.Mock).mock.calls[0][3]).toEqual({ + eventId: undefined, + note: 'new note', + timelineId: newTimelineSavedObjectId, + }); + }); + }); + + describe('create immutable templates', () => { + beforeAll(async () => { + (getNote as jest.Mock).mockReturnValue({ + ...notes[0], + }); + await module.createTimelines({ + frameworkRequest, + timeline: template, + timelineSavedObjectId, + timelineVersion, + pinnedEventIds, + notes, + existingNoteIds, + isImmutable, + overrideNotesOwner: false, + }); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + test('override timerange - start', () => { + expect((persistTimeline as jest.Mock).mock.calls[0][3].dateRange.start).toEqual( + '2020-11-03T11:37:31.655Z' + ); + }); + + test('override timerange - end', () => { + expect((persistTimeline as jest.Mock).mock.calls[0][3].dateRange.end).toEqual( + '2020-11-04T11:37:31.655Z' + ); + }); + }); + + describe('create custom templates', () => { + beforeAll(async () => { + await module.createTimelines({ + frameworkRequest, + timeline: template, + timelineSavedObjectId, + timelineVersion, + pinnedEventIds, + notes, + existingNoteIds, + isImmutable: false, + }); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + test('respect input timerange - start', () => { + expect((persistTimeline as jest.Mock).mock.calls[0][3].dateRange.start).toEqual( + '2020-10-01T11:37:31.655Z' + ); + }); + + test('respect input timerange - end', () => { + expect((persistTimeline as jest.Mock).mock.calls[0][3].dateRange.end).toEqual( + '2020-10-02T11:37:31.655Z' + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/create_timelines.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/create_timelines.ts index dc0caaf67d738..83f97ddb01eaa 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/create_timelines.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/create_timelines.ts @@ -5,6 +5,7 @@ */ import { isEmpty } from 'lodash/fp'; +import moment from 'moment'; import * as timelineLib from '../../saved_object'; import * as pinnedEventLib from '../../../pinned_event/saved_object'; import * as noteLib from '../../../note/saved_object'; @@ -128,15 +129,20 @@ export const createTimelines = async ({ isImmutable, overrideNotesOwner = true, }: CreateTimelineProps): Promise => { + const timerangeStart = isImmutable + ? moment().subtract(24, 'hours').toISOString() + : timeline.dateRange?.start; + const timerangeEnd = isImmutable ? moment().toISOString() : timeline.dateRange?.end; const responseTimeline = await saveTimelines( frameworkRequest, - timeline, + { ...timeline, dateRange: { start: timerangeStart, end: timerangeEnd } }, timelineSavedObjectId, timelineVersion, isImmutable ); const newTimelineSavedObjectId = responseTimeline.timeline.savedObjectId; const newTimelineVersion = responseTimeline.timeline.version; + let myPromises: unknown[] = []; if (pinnedEventIds != null && !isEmpty(pinnedEventIds)) { myPromises = [