From 7fa04e92bc7baff0fd6d2c13a17ab37d8d934a59 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Fri, 29 Sep 2023 11:52:39 +0200 Subject: [PATCH] [Kibana] New "Saved Query Management" privilege to allow saving queries across Kibana (#166937) - Resolves https://github.com/elastic/kibana/issues/158173 Based on PoC https://github.com/elastic/kibana/pull/166260 ## Summary This PR adds a new "Saved Query Management" privilege with 2 options: - `All` will override any per app privilege and will allow users to save queries from any Kibana page - `None` will default to per app privileges (backward-compatible option) Screenshot 2023-09-21 at 15 26 25 ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --------- Co-authored-by: Matthias Wilhelm Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Stratoula Kalafateli --- .buildkite/ftr_configs.yml | 1 + .github/CODEOWNERS | 1 + .../public/with_data_services/app.tsx | 2 +- .../top_nav/dashboard_top_nav.tsx | 4 +- .../application/context/context_app.test.tsx | 2 +- .../application/context/context_app.tsx | 2 +- .../top_nav/discover_topnav.test.tsx | 46 ++++- .../components/top_nav/discover_topnav.tsx | 4 +- .../public/top_nav_menu/top_nav_menu.tsx | 67 +++--- .../public/__stories__/search_bar.stories.tsx | 69 +++++-- .../public/search_bar/create_search_bar.tsx | 28 ++- .../lib/can_show_saved_query.test.ts | 107 ++++++++++ .../search_bar/lib/can_show_saved_query.ts | 45 +++++ .../public/search_bar/search_bar.tsx | 2 +- src/plugins/unified_search/tsconfig.json | 1 + .../components/visualize_top_nav.tsx | 6 +- .../layout/findings_search_bar.tsx | 2 +- .../__snapshots__/oss_features.test.ts.snap | 54 +++++ .../plugins/features/server/oss_features.ts | 25 +++ x-pack/plugins/features/server/plugin.test.ts | 1 + .../search_bar/unified_search_bar.tsx | 6 +- .../lens/public/app_plugin/app.test.tsx | 36 +++- .../lens/public/app_plugin/lens_top_nav.tsx | 6 +- .../routes/map_page/map_app/map_app.tsx | 4 +- .../common/components/search_bar/index.tsx | 2 +- .../search_source_expression_form.tsx | 2 +- .../alerts_search_bar/alerts_search_bar.tsx | 2 +- .../kql_search_bar/kql_search_bar.tsx | 2 +- .../apis/features/features/features.ts | 1 + .../apis/security/privileges.ts | 1 + .../apis/security/privileges_basic.ts | 2 + .../feature_controls/dashboard_security.ts | 2 + .../feature_controls/discover_security.ts | 109 +--------- .../group1/feature_controls/maps_security.ts | 2 + .../apps/saved_query_management/config.ts | 17 ++ .../feature_controls/index.ts | 15 ++ .../feature_controls/security.ts | 191 ++++++++++++++++++ .../apps/saved_query_management/index.ts | 14 ++ .../utils/saved_query_security.ts | 96 +++++++++ .../feature_controls/visualize_security.ts | 2 + 40 files changed, 798 insertions(+), 183 deletions(-) create mode 100644 src/plugins/unified_search/public/search_bar/lib/can_show_saved_query.test.ts create mode 100644 src/plugins/unified_search/public/search_bar/lib/can_show_saved_query.ts create mode 100644 x-pack/test/functional/apps/saved_query_management/config.ts create mode 100644 x-pack/test/functional/apps/saved_query_management/feature_controls/index.ts create mode 100644 x-pack/test/functional/apps/saved_query_management/feature_controls/security.ts create mode 100644 x-pack/test/functional/apps/saved_query_management/index.ts create mode 100644 x-pack/test/functional/apps/saved_query_management/utils/saved_query_security.ts diff --git a/.buildkite/ftr_configs.yml b/.buildkite/ftr_configs.yml index 309bf005084c..28355e941ef3 100644 --- a/.buildkite/ftr_configs.yml +++ b/.buildkite/ftr_configs.yml @@ -310,6 +310,7 @@ enabled: - x-pack/test/functional/apps/reporting_management/config.ts - x-pack/test/functional/apps/rollup_job/config.ts - x-pack/test/functional/apps/saved_objects_management/config.ts + - x-pack/test/functional/apps/saved_query_management/config.ts - x-pack/test/functional/apps/security/config.ts - x-pack/test/functional/apps/snapshot_restore/config.ts - x-pack/test/functional/apps/spaces/config.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8f9261eb8aed..c9f1926a01d2 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -853,6 +853,7 @@ packages/kbn-yarn-lock-validator @elastic/kibana-operations /x-pack/test/examples/search_examples @elastic/kibana-data-discovery /x-pack/test/functional/apps/data_views @elastic/kibana-data-discovery /x-pack/test/functional/apps/discover @elastic/kibana-data-discovery +/x-pack/test/functional/apps/saved_query_management @elastic/kibana-data-discovery /x-pack/test/functional_with_es_ssl/apps/discover_ml_uptime/discover @elastic/kibana-data-discovery /x-pack/test/search_sessions_integration @elastic/kibana-data-discovery /x-pack/test/stack_functional_integration/apps/ccs/ccs_discover.js @elastic/kibana-data-discovery diff --git a/examples/state_containers_examples/public/with_data_services/app.tsx b/examples/state_containers_examples/public/with_data_services/app.tsx index 2dda3faf0db8..8a1377c35b85 100644 --- a/examples/state_containers_examples/public/with_data_services/app.tsx +++ b/examples/state_containers_examples/public/with_data_services/app.tsx @@ -97,7 +97,7 @@ export const App = ({ showSearchBar={true} indexPatterns={[dataView]} useDefaultBehaviors={true} - showSaveQuery={true} + saveQueryMenuVisibility="allowed_by_app_privilege" // allowed only for this example app, use `globally_managed` by default /> diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_top_nav.tsx b/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_top_nav.tsx index 8963730fcb08..67c51a19052d 100644 --- a/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_top_nav.tsx +++ b/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_top_nav.tsx @@ -68,7 +68,7 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr navigation: { TopNavMenu }, embeddable: { getStateTransfer }, initializerContext: { allowByValueEmbeddables }, - dashboardCapabilities: { saveQuery: showSaveQuery, showWriteControls }, + dashboardCapabilities: { saveQuery: allowSaveQuery, showWriteControls }, } = pluginServices.getServices(); const isLabsEnabled = uiSettings.get(UI_SETTINGS.ENABLE_LABS_UI); const { setHeaderActionMenu, onAppLeave } = useDashboardMountContext(); @@ -298,7 +298,7 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr useDefaultBehaviors={true} savedQueryId={savedQueryId} indexPatterns={allDataViews} - showSaveQuery={showSaveQuery} + saveQueryMenuVisibility={allowSaveQuery ? 'allowed_by_app_privilege' : 'globally_managed'} appName={LEGACY_DASHBOARD_APP_ID} visible={viewMode !== ViewMode.PRINT} setMenuMountPoint={embedSettings || fullScreenMode ? undefined : setHeaderActionMenu} diff --git a/src/plugins/discover/public/application/context/context_app.test.tsx b/src/plugins/discover/public/application/context/context_app.test.tsx index c3f87b011725..b05dc3ca36e7 100644 --- a/src/plugins/discover/public/application/context/context_app.test.tsx +++ b/src/plugins/discover/public/application/context/context_app.test.tsx @@ -88,7 +88,7 @@ describe('ContextApp test', () => { showSearchBar: true, showQueryInput: false, showFilterBar: true, - showSaveQuery: false, + saveQueryMenuVisibility: 'hidden' as const, showDatePicker: false, indexPatterns: [dataViewMock], useDefaultBehaviors: true, diff --git a/src/plugins/discover/public/application/context/context_app.tsx b/src/plugins/discover/public/application/context/context_app.tsx index de1e0a23c4df..5c7372f8d1d2 100644 --- a/src/plugins/discover/public/application/context/context_app.tsx +++ b/src/plugins/discover/public/application/context/context_app.tsx @@ -207,7 +207,7 @@ export const ContextApp = ({ dataView, anchorId, referrer }: ContextAppProps) => showSearchBar: true, showQueryInput: false, showFilterBar: true, - showSaveQuery: false, + saveQueryMenuVisibility: 'hidden' as const, showDatePicker: false, indexPatterns: [dataView], useDefaultBehaviors: true, diff --git a/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.test.tsx b/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.test.tsx index acf966b22ce9..5b00f1ee6e75 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.test.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.test.tsx @@ -53,8 +53,20 @@ jest.mock('../../../../customizations', () => ({ useDiscoverCustomization: jest.fn(), })); -function getProps(savePermissions = true): DiscoverTopNavProps { - mockDiscoverService.capabilities.discover!.save = savePermissions; +const mockDefaultCapabilities = { + discover: { save: true }, +} as unknown as typeof mockDiscoverService.capabilities; + +function getProps( + { + capabilities, + }: { + capabilities?: Partial; + } = { capabilities: mockDefaultCapabilities } +): DiscoverTopNavProps { + if (capabilities) { + mockDiscoverService.capabilities = capabilities as typeof mockDiscoverService.capabilities; + } const stateContainer = getDiscoverStateMock({ isTimeBased: true }); stateContainer.internalState.transitions.setDataView(dataViewMock); @@ -93,7 +105,7 @@ describe('Discover topnav component', () => { }); test('generated config of TopNavMenu config is correct when discover save permissions are assigned', () => { - const props = getProps(true); + const props = getProps({ capabilities: { discover: { save: true } } }); const component = mountWithIntl( @@ -105,7 +117,7 @@ describe('Discover topnav component', () => { }); test('generated config of TopNavMenu config is correct when no discover save permissions are assigned', () => { - const props = getProps(false); + const props = getProps({ capabilities: { discover: { save: false } } }); const component = mountWithIntl( @@ -116,6 +128,32 @@ describe('Discover topnav component', () => { expect(topMenuConfig).toEqual(['new', 'open', 'share', 'inspect']); }); + test('top nav is correct when discover saveQuery permission is granted', () => { + const props = getProps({ capabilities: { discover: { saveQuery: true } } }); + const component = mountWithIntl( + + + + ); + const statefulSearchBar = component.find( + mockDiscoverService.navigation.ui.AggregateQueryTopNavMenu + ); + expect(statefulSearchBar.props().saveQueryMenuVisibility).toBe('allowed_by_app_privilege'); + }); + + test('top nav is correct when discover saveQuery permission is not granted', () => { + const props = getProps({ capabilities: { discover: { saveQuery: false } } }); + const component = mountWithIntl( + + + + ); + const statefulSearchBar = component.find( + mockDiscoverService.navigation.ui.AggregateQueryTopNavMenu + ); + expect(statefulSearchBar.props().saveQueryMenuVisibility).toBe('globally_managed'); + }); + describe('top nav customization', () => { it('should call getMenuItems', () => { mockUseCustomizations = true; diff --git a/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.tsx b/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.tsx index a743d4c1abeb..650d5141536e 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.tsx @@ -220,7 +220,9 @@ export const DiscoverTopNav = ({ savedQueryId={savedQuery} screenTitle={savedSearch.title} showDatePicker={showDatePicker} - showSaveQuery={!isPlainRecord && Boolean(services.capabilities.discover.saveQuery)} + saveQueryMenuVisibility={ + services.capabilities.discover.saveQuery ? 'allowed_by_app_privilege' : 'globally_managed' + } showSearchBar={true} useDefaultBehaviors={true} dataViewPickerOverride={ diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx index b74a8b64e8ee..3b3cac792181 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx @@ -20,7 +20,7 @@ import classNames from 'classnames'; import { MountPoint } from '@kbn/core/public'; import { MountPointPortal } from '@kbn/kibana-react-plugin/public'; import { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; -import { StatefulSearchBarProps, SearchBarProps } from '@kbn/unified-search-plugin/public'; +import { StatefulSearchBarProps } from '@kbn/unified-search-plugin/public'; import { AggregateQuery, Query } from '@kbn/es-query'; import { TopNavMenuData } from './top_nav_menu_data'; import { TopNavMenuItem } from './top_nav_menu_item'; @@ -30,38 +30,39 @@ type Badge = EuiBadgeProps & { toolTipProps?: Partial; }; -export type TopNavMenuProps = - StatefulSearchBarProps & - Omit, 'kibana' | 'intl' | 'timeHistory'> & { - config?: TopNavMenuData[]; - badges?: Badge[]; - showSearchBar?: boolean; - showQueryInput?: boolean; - showDatePicker?: boolean; - showFilterBar?: boolean; - unifiedSearch?: UnifiedSearchPublicPluginStart; - className?: string; - visible?: boolean; - /** - * If provided, the menu part of the component will be rendered as a portal inside the given mount point. - * - * This is meant to be used with the `setHeaderActionMenu` core API. - * - * @example - * ```ts - * export renderApp = ({ element, history, setHeaderActionMenu }: AppMountParameters) => { - * const topNavConfig = ...; // TopNavMenuProps - * return ( - * - * - * - * - * ) - * } - * ``` - */ - setMenuMountPoint?: (menuMount: MountPoint | undefined) => void; - }; +export type TopNavMenuProps = Omit< + StatefulSearchBarProps, + 'kibana' | 'intl' | 'timeHistory' +> & { + config?: TopNavMenuData[]; + badges?: Badge[]; + showSearchBar?: boolean; + showQueryInput?: boolean; + showDatePicker?: boolean; + showFilterBar?: boolean; + unifiedSearch?: UnifiedSearchPublicPluginStart; + className?: string; + visible?: boolean; + /** + * If provided, the menu part of the component will be rendered as a portal inside the given mount point. + * + * This is meant to be used with the `setHeaderActionMenu` core API. + * + * @example + * ```ts + * export renderApp = ({ element, history, setHeaderActionMenu }: AppMountParameters) => { + * const topNavConfig = ...; // TopNavMenuProps + * return ( + * + * + * + * + * ) + * } + * ``` + */ + setMenuMountPoint?: (menuMount: MountPoint | undefined) => void; +}; /* * Top Nav Menu is a convenience wrapper component for: diff --git a/src/plugins/unified_search/public/__stories__/search_bar.stories.tsx b/src/plugins/unified_search/public/__stories__/search_bar.stories.tsx index 5cf4795e1ee7..534cd48ade81 100644 --- a/src/plugins/unified_search/public/__stories__/search_bar.stories.tsx +++ b/src/plugins/unified_search/public/__stories__/search_bar.stories.tsx @@ -174,11 +174,20 @@ const services = { }, }; +const defaultCapabilities = { + savedObjectsManagement: { + edit: true, + }, +}; + setIndexPatterns({ get: () => Promise.resolve(mockIndexPatterns[0]), } as unknown as DataViewsContract); -function wrapSearchBarInContext(testProps: SearchBarProps) { +function wrapSearchBarInContext( + testProps: Partial>, + capabilities: typeof defaultCapabilities = defaultCapabilities +) { const defaultOptions = { appName: 'test', timeHistory: mockTimeHistory, @@ -197,9 +206,16 @@ function wrapSearchBarInContext(testProps: SearchBarProps) { onFiltersUpdated: action('onFiltersUpdated'), } as unknown as SearchBarProps; + const kbnServices = { + ...services, + application: { + capabilities, + }, + }; + return ( - + {...defaultOptions} {...testProps} /> @@ -219,7 +235,7 @@ storiesOf('SearchBar', module) }, onChangeDataView: action('onChangeDataView'), }, - } as SearchBarProps) + }) ) .add('with dataviewPicker enhanced', () => wrapSearchBarInContext({ @@ -234,41 +250,56 @@ storiesOf('SearchBar', module) onAddField: action('onAddField'), onDataViewCreated: action('onDataViewCreated'), }, - } as SearchBarProps) + }) ) .add('with filterBar off', () => wrapSearchBarInContext({ showFilterBar: false, - } as SearchBarProps) + }) ) .add('with query input off', () => wrapSearchBarInContext({ showQueryInput: false, - } as SearchBarProps) + }) ) .add('with date picker off', () => wrapSearchBarInContext({ showDatePicker: false, - } as SearchBarProps) + }) + ) + .add('with disabled "Save query" menu', () => + wrapSearchBarInContext({ + showSaveQuery: false, + }) + ) + .add('with hidden "Manage saved objects" link in "Load saved query" menu', () => + wrapSearchBarInContext( + {}, + { + savedObjectsManagement: { + edit: false, + }, + } + ) ) .add('with the default date picker auto refresh interval on', () => wrapSearchBarInContext({ showDatePicker: true, onRefreshChange: action('onRefreshChange'), - } as SearchBarProps) + }) ) .add('with the default date picker auto refresh interval off', () => wrapSearchBarInContext({ showDatePicker: true, isAutoRefreshDisabled: true, - } as SearchBarProps) + }) ) .add('with only the date picker on', () => wrapSearchBarInContext({ showDatePicker: true, showFilterBar: false, showQueryInput: false, - } as SearchBarProps) + }) ) .add('with additional filters used for suggestions', () => wrapSearchBarInContext({ @@ -470,12 +501,12 @@ storiesOf('SearchBar', module) /> ), showQueryInput: true, - } as SearchBarProps) + }) ) .add('without switch query language', () => wrapSearchBarInContext({ disableQueryLanguageSwitcher: true, - } as SearchBarProps) + }) ) .add('show only query bar without submit', () => wrapSearchBarInContext({ @@ -484,7 +515,7 @@ storiesOf('SearchBar', module) showAutoRefreshOnly: false, showQueryInput: true, showSubmitButton: false, - } as SearchBarProps) + }) ) .add('show only datepicker without submit', () => wrapSearchBarInContext({ @@ -493,7 +524,7 @@ storiesOf('SearchBar', module) showAutoRefreshOnly: false, showQueryInput: false, showSubmitButton: false, - } as SearchBarProps) + }) ) .add('show only query bar and timepicker without submit', () => wrapSearchBarInContext({ @@ -502,7 +533,7 @@ storiesOf('SearchBar', module) showAutoRefreshOnly: false, showQueryInput: true, showSubmitButton: false, - } as SearchBarProps) + }) ) .add('with filter bar on but pinning option is hidden from menus', () => wrapSearchBarInContext({ @@ -621,7 +652,7 @@ storiesOf('SearchBar', module) onChangeDataView: action('onChangeDataView'), }, isDisabled: true, - } as SearchBarProps) + }) ) .add('no submit button', () => wrapSearchBarInContext({ @@ -635,7 +666,7 @@ storiesOf('SearchBar', module) onChangeDataView: action('onChangeDataView'), }, showSubmitButton: false, - } as SearchBarProps) + }) ) .add('submit button always as icon', () => wrapSearchBarInContext({ @@ -649,7 +680,7 @@ storiesOf('SearchBar', module) onChangeDataView: action('onChangeDataView'), }, submitButtonStyle: 'iconOnly', - } as SearchBarProps) + }) ) .add('submit button always as a full button', () => wrapSearchBarInContext({ @@ -663,5 +694,5 @@ storiesOf('SearchBar', module) onChangeDataView: action('onChangeDataView'), }, submitButtonStyle: 'full', - } as SearchBarProps) + }) ); diff --git a/src/plugins/unified_search/public/search_bar/create_search_bar.tsx b/src/plugins/unified_search/public/search_bar/create_search_bar.tsx index 8ffca3fbb0bd..f1f631494dd5 100644 --- a/src/plugins/unified_search/public/search_bar/create_search_bar.tsx +++ b/src/plugins/unified_search/public/search_bar/create_search_bar.tsx @@ -21,6 +21,7 @@ import { useFilterManager } from './lib/use_filter_manager'; import { useTimefilter } from './lib/use_timefilter'; import { useSavedQuery } from './lib/use_saved_query'; import { useQueryStringManager } from './lib/use_query_string_manager'; +import { type SavedQueryMenuVisibility, canShowSavedQuery } from './lib/can_show_saved_query'; import type { UnifiedSearchPublicPluginStart } from '../types'; export interface StatefulSearchBarDeps { @@ -32,14 +33,17 @@ export interface StatefulSearchBarDeps { unifiedSearch: Omit; } -export type StatefulSearchBarProps = - SearchBarOwnProps & { - appName: string; - useDefaultBehaviors?: boolean; - savedQueryId?: string; - onSavedQueryIdChange?: (savedQueryId?: string) => void; - onFiltersUpdated?: (filters: Filter[]) => void; - }; +export type StatefulSearchBarProps = Omit< + SearchBarOwnProps, + 'showSaveQuery' +> & { + appName: string; + useDefaultBehaviors?: boolean; + savedQueryId?: string; + saveQueryMenuVisibility?: SavedQueryMenuVisibility; + onSavedQueryIdChange?: (savedQueryId?: string) => void; + onFiltersUpdated?: (filters: Filter[]) => void; +}; // Respond to user changing the filters const defaultFiltersUpdated = ( @@ -194,6 +198,12 @@ export function createSearchBar({ ); }, [query, timeRange, useDefaultBehaviors]); + const showSaveQuery = canShowSavedQuery({ + saveQueryMenuVisibility: props.saveQueryMenuVisibility, + query, + core, + }); + return ( { + it('should allow when allowed_by_app_privilege', async () => { + expect( + canShowSavedQuery({ + core: coreWithoutGlobalPrivilege, + query: kqlQuery, + saveQueryMenuVisibility: 'allowed_by_app_privilege', + }) + ).toBe(true); + }); + + it('should not allow for text-based queries when allowed_by_app_privilege', async () => { + expect( + canShowSavedQuery({ + core: coreWithoutGlobalPrivilege, + query: esqlQuery, + saveQueryMenuVisibility: 'allowed_by_app_privilege', + }) + ).toBe(false); + }); + + it('should not allow for text-based queries when globally_managed', async () => { + expect( + canShowSavedQuery({ + core: coreWithGlobalPrivilege, + query: esqlQuery, + saveQueryMenuVisibility: 'globally_managed', + }) + ).toBe(false); + }); + + it('should allow when globally allowed', async () => { + expect( + canShowSavedQuery({ + core: coreWithGlobalPrivilege, + query: kqlQuery, + saveQueryMenuVisibility: 'globally_managed', + }) + ).toBe(true); + }); + + it('should not allow when globally disallowed', async () => { + expect( + canShowSavedQuery({ + core: coreWithoutGlobalPrivilege, + query: kqlQuery, + saveQueryMenuVisibility: 'globally_managed', + }) + ).toBe(false); + }); + + it('should not allow when hidden', async () => { + expect( + canShowSavedQuery({ + core: coreWithGlobalPrivilege, + query: kqlQuery, + saveQueryMenuVisibility: 'hidden', + }) + ).toBe(false); + + expect( + canShowSavedQuery({ + core: coreWithGlobalPrivilege, + query: kqlQuery, + saveQueryMenuVisibility: undefined, + }) + ).toBe(false); + }); +}); diff --git a/src/plugins/unified_search/public/search_bar/lib/can_show_saved_query.ts b/src/plugins/unified_search/public/search_bar/lib/can_show_saved_query.ts new file mode 100644 index 000000000000..bab2fbbe2eb7 --- /dev/null +++ b/src/plugins/unified_search/public/search_bar/lib/can_show_saved_query.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { AggregateQuery, Query } from '@kbn/es-query'; +import type { CoreStart } from '@kbn/core-lifecycle-browser'; +import { isOfAggregateQueryType } from '@kbn/es-query'; + +export type SavedQueryMenuVisibility = + | 'hidden' + | 'globally_managed' // managed by "Saved Query Management" global privilege + | 'allowed_by_app_privilege'; // use only if your Kibana app grants this privilege, otherwise default to `globally_managed` + +export const canShowSavedQuery = ({ + saveQueryMenuVisibility = 'hidden', + query, + core, +}: { + saveQueryMenuVisibility?: SavedQueryMenuVisibility; + query: AggregateQuery | Query | { [key: string]: any }; + core: CoreStart; +}): boolean => { + // don't show Saved Query menu by default + if (!saveQueryMenuVisibility || saveQueryMenuVisibility === 'hidden') { + return false; + } + + // Saved Queries are not supported for text-based languages (only Saved Searches) + if (isOfAggregateQueryType(query)) { + return false; + } + + const isAllowedGlobally = Boolean(core.application.capabilities.savedQueryManagement?.saveQuery); + + // users can allow saving queries globally or grant permission per app + if (saveQueryMenuVisibility === 'allowed_by_app_privilege') { + return true; + } + + return isAllowedGlobally; +}; diff --git a/src/plugins/unified_search/public/search_bar/search_bar.tsx b/src/plugins/unified_search/public/search_bar/search_bar.tsx index b2d3b5abc966..eb843ee0517a 100644 --- a/src/plugins/unified_search/public/search_bar/search_bar.tsx +++ b/src/plugins/unified_search/public/search_bar/search_bar.tsx @@ -68,7 +68,7 @@ export interface SearchBarOwnProps { dateRangeTo?: string; // Query bar - should be in SearchBarInjectedDeps query?: QT | Query; - // Show when user has privileges to save + // Show when user has privileges to save. See `canShowSavedQuery(...)` lib. showSaveQuery?: boolean; savedQuery?: SavedQuery; onQueryChange?: (payload: { dateRange: TimeRange; query?: QT | Query }) => void; diff --git a/src/plugins/unified_search/tsconfig.json b/src/plugins/unified_search/tsconfig.json index 8ea28fd9b7f6..f9c1724e969f 100644 --- a/src/plugins/unified_search/tsconfig.json +++ b/src/plugins/unified_search/tsconfig.json @@ -40,6 +40,7 @@ "@kbn/text-based-languages", "@kbn/text-based-editor", "@kbn/core-doc-links-browser", + "@kbn/core-lifecycle-browser", ], "exclude": [ "target/**/*", diff --git a/src/plugins/visualizations/public/visualize_app/components/visualize_top_nav.tsx b/src/plugins/visualizations/public/visualize_app/components/visualize_top_nav.tsx index 7e8bf9002af3..5a4e10507701 100644 --- a/src/plugins/visualizations/public/visualize_app/components/visualize_top_nav.tsx +++ b/src/plugins/visualizations/public/visualize_app/components/visualize_top_nav.tsx @@ -307,7 +307,9 @@ const TopNav = ({ showDatePicker={showDatePicker()} showFilterBar={showFilterBar} showQueryInput={showQueryInput} - showSaveQuery={Boolean(services.visualizeCapabilities.saveQuery)} + saveQueryMenuVisibility={ + services.visualizeCapabilities.saveQuery ? 'allowed_by_app_privilege' : 'globally_managed' + } dataViewPickerComponentProps={ shouldShowDataViewPicker && vis.data.indexPattern ? { @@ -346,7 +348,7 @@ const TopNav = ({ setMenuMountPoint={setHeaderActionMenu} indexPatterns={indexPatterns} showSearchBar - showSaveQuery={false} + saveQueryMenuVisibility="hidden" showDatePicker={false} showQueryInput={false} /> diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/layout/findings_search_bar.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/layout/findings_search_bar.tsx index 22c2c8ff4d7a..9b6e7bcb60c5 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/layout/findings_search_bar.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/layout/findings_search_bar.tsx @@ -48,7 +48,7 @@ export const FindingsSearchBar = ({ showFilterBar={true} showQueryInput={true} showDatePicker={false} - showSaveQuery={false} + saveQueryMenuVisibility="hidden" isLoading={loading} indexPatterns={[dataView]} onQuerySubmit={setQuery} 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 3edffdda8686..62e868e77e52 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 @@ -177,6 +177,10 @@ Array [ "id": "savedObjectsManagement", "subFeatures": undefined, }, + Object { + "id": "savedQueryManagement", + "subFeatures": undefined, + }, ] `; @@ -469,6 +473,10 @@ Array [ "id": "savedObjectsManagement", "subFeatures": undefined, }, + Object { + "id": "savedQueryManagement", + "subFeatures": undefined, + }, ] `; @@ -975,6 +983,29 @@ Array [ ] `; +exports[`buildOSSFeatures with a basic license returns the savedQueryManagement feature augmented with appropriate sub feature privileges 1`] = ` +Array [ + Object { + "privilege": Object { + "app": Array [ + "kibana", + ], + "catalogue": Array [], + "savedObject": Object { + "all": Array [ + "query", + ], + "read": Array [], + }, + "ui": Array [ + "saveQuery", + ], + }, + "privilegeId": "all", + }, +] +`; + exports[`buildOSSFeatures with a basic license returns the visualize feature augmented with appropriate sub feature privileges 1`] = ` Array [ Object { @@ -1563,6 +1594,29 @@ Array [ ] `; +exports[`buildOSSFeatures with a enterprise license returns the savedQueryManagement feature augmented with appropriate sub feature privileges 1`] = ` +Array [ + Object { + "privilege": Object { + "app": Array [ + "kibana", + ], + "catalogue": Array [], + "savedObject": Object { + "all": Array [ + "query", + ], + "read": Array [], + }, + "ui": Array [ + "saveQuery", + ], + }, + "privilegeId": "all", + }, +] +`; + exports[`buildOSSFeatures with a enterprise license returns the visualize feature augmented with appropriate sub feature privileges 1`] = ` Array [ Object { diff --git a/x-pack/plugins/features/server/oss_features.ts b/x-pack/plugins/features/server/oss_features.ts index 4097780cdfe1..1fee088a2518 100644 --- a/x-pack/plugins/features/server/oss_features.ts +++ b/x-pack/plugins/features/server/oss_features.ts @@ -518,6 +518,31 @@ export const buildOSSFeatures = ({ }, }, }, + { + id: 'savedQueryManagement', + name: i18n.translate('xpack.features.savedQueryManagementFeatureName', { + defaultMessage: 'Saved Query Management', + }), + order: 1750, + category: DEFAULT_APP_CATEGORIES.management, + app: ['kibana'], + catalogue: [], + privilegesTooltip: i18n.translate('xpack.features.savedQueryManagementTooltip', { + defaultMessage: + 'If set to "All", saved queries can be managed across Kibana in all applications that support them. If set to "None", saved query privileges will be determined independently by each application.', + }), + privileges: { + all: { + app: ['kibana'], + catalogue: [], + savedObject: { + all: ['query'], + read: [], + }, + ui: ['saveQuery'], + }, // No read-only mode supported + }, + }, ] as KibanaFeatureConfig[]; }; diff --git a/x-pack/plugins/features/server/plugin.test.ts b/x-pack/plugins/features/server/plugin.test.ts index e1eac3ebbd48..ca88da045884 100644 --- a/x-pack/plugins/features/server/plugin.test.ts +++ b/x-pack/plugins/features/server/plugin.test.ts @@ -69,6 +69,7 @@ describe('Features Plugin', () => { "filesManagement", "filesSharedImage", "savedObjectsManagement", + "savedQueryManagement", ] `); }); diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/search_bar/unified_search_bar.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/search_bar/unified_search_bar.tsx index ea5f25880f7d..83a30e1d8441 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/search_bar/unified_search_bar.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/search_bar/unified_search_bar.tsx @@ -57,7 +57,11 @@ export const UnifiedSearchBar = () => { defaultMessage: 'Search hosts (E.g. cloud.provider:gcp AND system.load.1 > 0.5)', })} onQuerySubmit={handleRefresh} - showSaveQuery={Boolean(application?.capabilities?.visualize?.saveQuery)} + saveQueryMenuVisibility={ + application?.capabilities?.visualize?.saveQuery + ? 'allowed_by_app_privilege' + : 'globally_managed' + } showDatePicker showFilterBar showQueryInput diff --git a/x-pack/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/plugins/lens/public/app_plugin/app.test.tsx index 30311af0ba43..9f5a9eb6d5a4 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -930,6 +930,38 @@ describe('Lens App', () => { instance.update(); expect(instance.find(SavedObjectSaveModal).prop('showCopyOnSave')).toEqual(false); }); + + it('enables Save Query UI when user has app-level permissions', async () => { + const services = makeDefaultServicesForApp(); + services.application = { + ...services.application, + capabilities: { + ...services.application.capabilities, + visualize: { saveQuery: true }, + }, + }; + const { instance } = await mountWith({ services }); + await act(async () => { + const topNavMenu = instance.find(services.navigation.ui.AggregateQueryTopNavMenu); + expect(topNavMenu.props().saveQueryMenuVisibility).toBe('allowed_by_app_privilege'); + }); + }); + + it('checks global save query permission when user does not have app-level permissions', async () => { + const services = makeDefaultServicesForApp(); + services.application = { + ...services.application, + capabilities: { + ...services.application.capabilities, + visualize: { saveQuery: false }, + }, + }; + const { instance } = await mountWith({ services }); + await act(async () => { + const topNavMenu = instance.find(services.navigation.ui.AggregateQueryTopNavMenu); + expect(topNavMenu.props().saveQueryMenuVisibility).toBe('globally_managed'); + }); + }); }); }); @@ -1187,7 +1219,7 @@ describe('Lens App', () => { }; await mountWith({ services }); expect(services.navigation.ui.AggregateQueryTopNavMenu).toHaveBeenCalledWith( - expect.objectContaining({ showSaveQuery: false }), + expect.objectContaining({ saveQueryMenuVisibility: 'globally_managed' }), {} ); }); @@ -1196,7 +1228,7 @@ describe('Lens App', () => { const { instance, services } = await mountWith({}); expect(services.navigation.ui.AggregateQueryTopNavMenu).toHaveBeenCalledWith( expect.objectContaining({ - showSaveQuery: true, + saveQueryMenuVisibility: 'allowed_by_app_privilege', savedQuery: undefined, onSaved: expect.any(Function), onSavedQueryUpdated: expect.any(Function), diff --git a/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx b/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx index dc4707a809bc..3a285ec4f33c 100644 --- a/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx +++ b/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx @@ -1054,7 +1054,11 @@ export const LensTopNavMenu = ({ { showSearchBar={true} showFilterBar={true} showDatePicker={true} - showSaveQuery={!!getMapsCapabilities().saveQuery} + saveQueryMenuVisibility={ + getMapsCapabilities().saveQuery ? 'allowed_by_app_privilege' : 'globally_managed' + } savedQuery={this.state.savedQuery} onSaved={this._updateStateFromSavedQuery} onSavedQueryUpdated={this._updateStateFromSavedQuery} diff --git a/x-pack/plugins/security_solution/public/common/components/search_bar/index.tsx b/x-pack/plugins/security_solution/public/common/components/search_bar/index.tsx index 55571cc9dfa6..e30789bfe35b 100644 --- a/x-pack/plugins/security_solution/public/common/components/search_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/search_bar/index.tsx @@ -329,7 +329,7 @@ export const SearchBarComponent = memo( showFilterBar={!hideFilterBar} showDatePicker={true} showQueryInput={!hideQueryInput} - showSaveQuery={true} + saveQueryMenuVisibility="allowed_by_app_privilege" dataTestSubj={dataTestSubj} /> diff --git a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/search_source_expression_form.tsx b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/search_source_expression_form.tsx index 7117455d30cc..c4419a780809 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/search_source_expression_form.tsx +++ b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/search_source_expression_form.tsx @@ -328,7 +328,7 @@ export const SearchSourceExpressionForm = (props: SearchSourceExpressionFormProp onClearSavedQuery={onClearSavedQuery} onSavedQueryUpdated={onSavedQuery} onSaved={onSavedQuery} - showSaveQuery + saveQueryMenuVisibility="allowed_by_app_privilege" showQueryInput showFilterBar showDatePicker={false} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_search_bar/alerts_search_bar.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_search_bar/alerts_search_bar.tsx index 5e5ced6d580e..077be38b5616 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_search_bar/alerts_search_bar.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_search_bar/alerts_search_bar.tsx @@ -101,7 +101,7 @@ export function AlertsSearchBar({ onRefresh={onRefresh} showDatePicker={showDatePicker} showQueryInput={true} - showSaveQuery={true} + saveQueryMenuVisibility="allowed_by_app_privilege" showSubmitButton={showSubmitButton} submitOnBlur={submitOnBlur} onQueryChange={onSearchQueryChange} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/kql_search_bar/kql_search_bar.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/kql_search_bar/kql_search_bar.tsx index 338aeddeaad9..87e68b7a27cc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/kql_search_bar/kql_search_bar.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/kql_search_bar/kql_search_bar.tsx @@ -88,7 +88,7 @@ export const KqlSearchBar = React.memo(({ onQuerySubmit }) => indexPatterns={loading || error ? NO_INDEX_PATTERNS : dataView} showAutoRefreshOnly={false} showDatePicker={false} - showSaveQuery={false} + saveQueryMenuVisibility="hidden" showQueryInput={true} showQueryMenu={false} showFilterBar={true} diff --git a/x-pack/test/api_integration/apis/features/features/features.ts b/x-pack/test/api_integration/apis/features/features/features.ts index a80a39e4af5d..088b179f625d 100644 --- a/x-pack/test/api_integration/apis/features/features/features.ts +++ b/x-pack/test/api_integration/apis/features/features/features.ts @@ -110,6 +110,7 @@ export default function ({ getService }: FtrProviderContext) { 'observabilityAIAssistant', 'observabilityCases', 'savedObjectsManagement', + 'savedQueryManagement', 'savedObjectsTagging', 'ml', 'apm', diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts index c786a41411a5..81cceb6561bd 100644 --- a/x-pack/test/api_integration/apis/security/privileges.ts +++ b/x-pack/test/api_integration/apis/security/privileges.ts @@ -82,6 +82,7 @@ export default function ({ getService }: FtrProviderContext) { advancedSettings: ['all', 'read', 'minimal_all', 'minimal_read'], indexPatterns: ['all', 'read', 'minimal_all', 'minimal_read'], savedObjectsManagement: ['all', 'read', 'minimal_all', 'minimal_read'], + savedQueryManagement: ['all', 'minimal_all'], osquery: [ 'all', 'read', diff --git a/x-pack/test/api_integration/apis/security/privileges_basic.ts b/x-pack/test/api_integration/apis/security/privileges_basic.ts index 6c6d32c1cb1e..174ac2a3c8f6 100644 --- a/x-pack/test/api_integration/apis/security/privileges_basic.ts +++ b/x-pack/test/api_integration/apis/security/privileges_basic.ts @@ -27,6 +27,7 @@ export default function ({ getService }: FtrProviderContext) { advancedSettings: ['all', 'read', 'minimal_all', 'minimal_read'], indexPatterns: ['all', 'read', 'minimal_all', 'minimal_read'], savedObjectsManagement: ['all', 'read', 'minimal_all', 'minimal_read'], + savedQueryManagement: ['all', 'minimal_all'], savedObjectsTagging: ['all', 'read', 'minimal_all', 'minimal_read'], graph: ['all', 'read', 'minimal_all', 'minimal_read'], maps: ['all', 'read', 'minimal_all', 'minimal_read'], @@ -165,6 +166,7 @@ export default function ({ getService }: FtrProviderContext) { filesManagement: ['all', 'read', 'minimal_all', 'minimal_read'], filesSharedImage: ['all', 'read', 'minimal_all', 'minimal_read'], savedObjectsManagement: ['all', 'read', 'minimal_all', 'minimal_read'], + savedQueryManagement: ['all', 'minimal_all'], osquery: [ 'all', 'read', diff --git a/x-pack/test/functional/apps/dashboard/group1/feature_controls/dashboard_security.ts b/x-pack/test/functional/apps/dashboard/group1/feature_controls/dashboard_security.ts index 5deaac7e3a57..584822a31161 100644 --- a/x-pack/test/functional/apps/dashboard/group1/feature_controls/dashboard_security.ts +++ b/x-pack/test/functional/apps/dashboard/group1/feature_controls/dashboard_security.ts @@ -33,6 +33,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { shouldLoginIfPrompted: false, }; + // more tests are in x-pack/test/functional/apps/saved_query_management/feature_controls/security.ts + describe('dashboard feature controls security', () => { before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/logstash_functional'); diff --git a/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts b/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts index 5657f9c5da26..ee810f7ddebf 100644 --- a/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts +++ b/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts @@ -7,8 +7,11 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../ftr_provider_context'; +import { getSavedQuerySecurityUtils } from '../../saved_query_management/utils/saved_query_security'; -export default function ({ getPageObjects, getService }: FtrProviderContext) { +export default function (ctx: FtrProviderContext) { + const { getPageObjects, getService } = ctx; + const savedQuerySecurityUtils = getSavedQuerySecurityUtils(ctx); const esArchiver = getService('esArchiver'); const esSupertest = getService('esSupertest'); const dataGrid = getService('dataGrid'); @@ -31,8 +34,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { ]); const testSubjects = getService('testSubjects'); const appsMenu = getService('appsMenu'); - const queryBar = getService('queryBar'); - const savedQueryManagementComponent = getService('savedQueryManagementComponent'); const kibanaServer = getService('kibanaServer'); const logstashIndexName = 'logstash-2015.09.22'; @@ -40,6 +41,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.timePicker.setDefaultAbsoluteRange(); } + // more tests are in x-pack/test/functional/apps/saved_query_management/feature_controls/security.ts + describe('discover feature controls security', () => { before(async () => { await kibanaServer.importExport.load( @@ -129,53 +132,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.share.clickShareTopNavButton(); }); - it('allows saving via the saved query management component popover with no saved query loaded', async () => { - await queryBar.setQuery('response:200'); - await savedQueryManagementComponent.saveNewQuery('foo', 'bar', true, false); - await savedQueryManagementComponent.savedQueryExistOrFail('foo'); - await savedQueryManagementComponent.closeSavedQueryManagementComponent(); - - await savedQueryManagementComponent.deleteSavedQuery('foo'); - await savedQueryManagementComponent.savedQueryMissingOrFail('foo'); - }); - - it('allow saving changes to a currently loaded query via the saved query management component', async () => { - await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); - await queryBar.setQuery('response:404'); - await savedQueryManagementComponent.updateCurrentlyLoadedQuery( - 'new description', - true, - false - ); - await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); - await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); - const queryString = await queryBar.getQueryString(); - expect(queryString).to.eql('response:404'); - - // Reset after changing - await queryBar.setQuery('response:200'); - await savedQueryManagementComponent.updateCurrentlyLoadedQuery( - 'Ok responses for jpg files', - true, - false - ); - }); - - it('allow saving currently loaded query as a copy', async () => { - await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); - await queryBar.setQuery('response:404'); - await savedQueryManagementComponent.saveCurrentlyLoadedAsNewQuery( - 'ok2', - 'description', - true, - false - ); - await PageObjects.header.waitUntilLoadingHasFinished(); - await savedQueryManagementComponent.savedQueryExistOrFail('ok2'); - await savedQueryManagementComponent.closeSavedQueryManagementComponent(); - await testSubjects.click('showQueryBarMenu'); - await savedQueryManagementComponent.deleteSavedQuery('ok2'); - }); + savedQuerySecurityUtils.shouldAllowSavingQueries(); }); describe('global discover read-only privileges', () => { @@ -245,33 +202,10 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.share.clickShareTopNavButton(); }); - it('allows loading a saved query via the saved query management component', async () => { - await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); - const queryString = await queryBar.getQueryString(); - expect(queryString).to.eql('response:200'); - }); - - it('does not allow saving via the saved query management component popover with no query loaded', async () => { - await savedQueryManagementComponent.saveNewQueryMissingOrFail(); - }); - - it('does not allow saving changes to saved query from the saved query management component', async () => { - await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); - await queryBar.setQuery('response:404'); - await savedQueryManagementComponent.updateCurrentlyLoadedQueryMissingOrFail(); - }); - - it('does not allow deleting a saved query from the saved query management component', async () => { - await savedQueryManagementComponent.deleteSavedQueryMissingOrFail('OKJpgs'); - }); - - it('allows clearing the currently loaded saved query', async () => { - await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); - await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); - }); + savedQuerySecurityUtils.shouldDisallowSavingButAllowLoadingSavedQueries(); }); - describe('global discover read-only privileges with url_create', () => { + describe('discover read-only privileges with url_create', () => { before(async () => { await security.role.create('global_discover_read_url_create_role', { elasticsearch: { @@ -338,30 +272,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.share.clickShareTopNavButton(); }); - it('allows loading a saved query via the saved query management component', async () => { - await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); - const queryString = await queryBar.getQueryString(); - expect(queryString).to.eql('response:200'); - }); - - it('does not allow saving via the saved query management component popover with no query loaded', async () => { - await savedQueryManagementComponent.saveNewQueryMissingOrFail(); - }); - - it('does not allow saving changes to saved query from the saved query management component', async () => { - await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); - await queryBar.setQuery('response:404'); - await savedQueryManagementComponent.updateCurrentlyLoadedQueryMissingOrFail(); - }); - - it('does not allow deleting a saved query from the saved query management component', async () => { - await savedQueryManagementComponent.deleteSavedQueryMissingOrFail('OKJpgs'); - }); - - it('allows clearing the currently loaded saved query', async () => { - await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); - await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); - }); + savedQuerySecurityUtils.shouldDisallowSavingButAllowLoadingSavedQueries(); }); describe('discover and visualize privileges', () => { diff --git a/x-pack/test/functional/apps/maps/group1/feature_controls/maps_security.ts b/x-pack/test/functional/apps/maps/group1/feature_controls/maps_security.ts index 94f46763acd3..91070028b2eb 100644 --- a/x-pack/test/functional/apps/maps/group1/feature_controls/maps_security.ts +++ b/x-pack/test/functional/apps/maps/group1/feature_controls/maps_security.ts @@ -17,6 +17,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const queryBar = getService('queryBar'); const savedQueryManagementComponent = getService('savedQueryManagementComponent'); + // more tests are in x-pack/test/functional/apps/saved_query_management/feature_controls/security.ts + describe('maps security feature controls', () => { after(async () => { // logout, so the other tests don't accidentally run as the custom users we're testing below diff --git a/x-pack/test/functional/apps/saved_query_management/config.ts b/x-pack/test/functional/apps/saved_query_management/config.ts new file mode 100644 index 000000000000..d0d07ff20028 --- /dev/null +++ b/x-pack/test/functional/apps/saved_query_management/config.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrConfigProviderContext } from '@kbn/test'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const functionalConfig = await readConfigFile(require.resolve('../../config.base.js')); + + return { + ...functionalConfig.getAll(), + testFiles: [require.resolve('.')], + }; +} diff --git a/x-pack/test/functional/apps/saved_query_management/feature_controls/index.ts b/x-pack/test/functional/apps/saved_query_management/feature_controls/index.ts new file mode 100644 index 000000000000..4c7c03dd0833 --- /dev/null +++ b/x-pack/test/functional/apps/saved_query_management/feature_controls/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('Feature controls', function () { + this.tags('skipFirefox'); + loadTestFile(require.resolve('./security')); + }); +} diff --git a/x-pack/test/functional/apps/saved_query_management/feature_controls/security.ts b/x-pack/test/functional/apps/saved_query_management/feature_controls/security.ts new file mode 100644 index 000000000000..911b8675adf1 --- /dev/null +++ b/x-pack/test/functional/apps/saved_query_management/feature_controls/security.ts @@ -0,0 +1,191 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { getSavedQuerySecurityUtils } from '../utils/saved_query_security'; + +type AppName = 'discover' | 'dashboard' | 'maps' | 'visualize'; + +const apps: AppName[] = ['discover', 'dashboard', 'maps', 'visualize']; + +export default function (ctx: FtrProviderContext) { + const { getPageObjects, getService } = ctx; + const savedQuerySecurityUtils = getSavedQuerySecurityUtils(ctx); + const esArchiver = getService('esArchiver'); + const security = getService('security'); + const globalNav = getService('globalNav'); + const PageObjects = getPageObjects([ + 'common', + 'discover', + 'security', + 'dashboard', + 'maps', + 'visualize', + ]); + const kibanaServer = getService('kibanaServer'); + + async function login( + appName: AppName, + appPrivilege: 'read' | 'all', + globalPrivilege: 'none' | 'all' + ) { + const name = `global_saved_query_${appName}`; + const password = `password_${name}_${appPrivilege}_${globalPrivilege}`; + + await security.role.create(name, { + elasticsearch: { + indices: [{ names: ['logstash-*'], privileges: ['read', 'view_index_metadata'] }], + }, + kibana: [ + { + feature: { + [appName]: [appPrivilege], + savedQueryManagement: [globalPrivilege], + }, + spaces: ['*'], + }, + ], + }); + + await security.user.create(`${name}-user`, { + password, + roles: [name], + full_name: 'test user', + }); + + await PageObjects.security.login(`${name}-user`, password, { + expectSpaceSelector: false, + }); + } + + async function logout(appName: AppName) { + const name = `global_saved_query_${appName}`; + await PageObjects.security.forceLogout(); + await security.role.delete(name); + await security.user.delete(`${name}-user`); + } + + async function navigateToApp(appName: AppName) { + switch (appName) { + case 'discover': + await PageObjects.common.navigateToApp('discover'); + await PageObjects.discover.selectIndexPattern('logstash-*'); + break; + case 'dashboard': + await PageObjects.dashboard.navigateToApp(); + await PageObjects.dashboard.gotoDashboardEditMode('A Dashboard'); + break; + case 'maps': + await PageObjects.maps.openNewMap(); + break; + case 'visualize': + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + break; + default: + break; + } + } + + describe('Security: App vs Global privilege', () => { + apps.forEach((appName) => { + before(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + + await kibanaServer.importExport.load( + 'x-pack/test/functional/fixtures/kbn_archiver/dashboard/feature_controls/security/security.json' + ); + + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/logstash_functional'); + + // ensure we're logged out, so we can log in as the appropriate users + await PageObjects.security.forceLogout(); + }); + + after(async () => { + // logout, so the other tests don't accidentally run as the custom users we're testing below + // NOTE: Logout needs to happen before anything else to avoid flaky behavior + await PageObjects.security.forceLogout(); + + await kibanaServer.importExport.unload( + 'x-pack/test/functional/fixtures/kbn_archiver/dashboard/feature_controls/security/security.json' + ); + + await kibanaServer.savedObjects.cleanStandardList(); + }); + + describe(`${appName} read-only privileges with enabled savedQueryManagement.saveQuery privilege`, () => { + before(async () => { + await login(appName, 'read', 'all'); + await navigateToApp(appName); + await PageObjects.common.waitForTopNavToBeVisible(); + }); + + after(async () => { + await logout(appName); + }); + + it('shows read-only badge', async () => { + await globalNav.badgeExistsOrFail('Read only'); + }); + + savedQuerySecurityUtils.shouldAllowSavingQueries(); + }); + + describe(`${appName} read-only privileges with disabled savedQueryManagement.saveQuery privilege`, () => { + before(async () => { + await login(appName, 'read', 'none'); + await navigateToApp(appName); + }); + + after(async () => { + await logout(appName); + }); + + it('shows read-only badge', async () => { + await globalNav.badgeExistsOrFail('Read only'); + }); + + savedQuerySecurityUtils.shouldDisallowSavingButAllowLoadingSavedQueries(); + }); + + describe(`${appName} all privileges with enabled savedQueryManagement.saveQuery privilege`, () => { + before(async () => { + await login(appName, 'all', 'all'); + await navigateToApp(appName); + }); + + after(async () => { + await logout(appName); + }); + + it("doesn't show read-only badge", async () => { + await globalNav.badgeMissingOrFail(); + }); + + savedQuerySecurityUtils.shouldAllowSavingQueries(); + }); + + describe(`${appName} all privileges with disabled savedQueryManagement.saveQuery privilege`, () => { + before(async () => { + await login(appName, 'all', 'none'); + await navigateToApp(appName); + }); + + after(async () => { + await logout(appName); + }); + + it("doesn't show read-only badge", async () => { + await globalNav.badgeMissingOrFail(); + }); + + savedQuerySecurityUtils.shouldAllowSavingQueries(); + }); + }); + }); +} diff --git a/x-pack/test/functional/apps/saved_query_management/index.ts b/x-pack/test/functional/apps/saved_query_management/index.ts new file mode 100644 index 000000000000..fb74e8ba554c --- /dev/null +++ b/x-pack/test/functional/apps/saved_query_management/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('Saved query management', function () { + loadTestFile(require.resolve('./feature_controls')); + }); +} diff --git a/x-pack/test/functional/apps/saved_query_management/utils/saved_query_security.ts b/x-pack/test/functional/apps/saved_query_management/utils/saved_query_security.ts new file mode 100644 index 000000000000..dd5dccec561f --- /dev/null +++ b/x-pack/test/functional/apps/saved_query_management/utils/saved_query_security.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export function getSavedQuerySecurityUtils({ getPageObjects, getService }: FtrProviderContext) { + const PageObjects = getPageObjects(['header']); + const testSubjects = getService('testSubjects'); + const queryBar = getService('queryBar'); + const savedQueryManagementComponent = getService('savedQueryManagementComponent'); + + return { + shouldAllowSavingQueries: () => { + { + it('allows saving via the saved query management component popover with no saved query loaded', async () => { + await queryBar.setQuery('response:200'); + await savedQueryManagementComponent.saveNewQuery('foo', 'bar', true, false); + await savedQueryManagementComponent.savedQueryExistOrFail('foo'); + await savedQueryManagementComponent.closeSavedQueryManagementComponent(); + + await savedQueryManagementComponent.deleteSavedQuery('foo'); + await savedQueryManagementComponent.savedQueryMissingOrFail('foo'); + }); + + it('allow saving changes to a currently loaded query via the saved query management component', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + await queryBar.setQuery('response:404'); + await savedQueryManagementComponent.updateCurrentlyLoadedQuery( + 'new description', + true, + false + ); + await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + const queryString = await queryBar.getQueryString(); + expect(queryString).to.eql('response:404'); + + // Reset after changing + await queryBar.setQuery('response:200'); + await savedQueryManagementComponent.updateCurrentlyLoadedQuery( + 'Ok responses for jpg files', + true, + false + ); + }); + + it('allow saving currently loaded query as a copy', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + await queryBar.setQuery('response:404'); + await savedQueryManagementComponent.saveCurrentlyLoadedAsNewQuery( + 'ok2', + 'description', + true, + false + ); + await PageObjects.header.waitUntilLoadingHasFinished(); + await savedQueryManagementComponent.savedQueryExistOrFail('ok2'); + await savedQueryManagementComponent.closeSavedQueryManagementComponent(); + await testSubjects.click('showQueryBarMenu'); + await savedQueryManagementComponent.deleteSavedQuery('ok2'); + }); + } + }, + shouldDisallowSavingButAllowLoadingSavedQueries: () => { + it('allows loading a saved query via the saved query management component', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + const queryString = await queryBar.getQueryString(); + expect(queryString).to.eql('response:200'); + }); + + it('does not allow saving via the saved query management component popover with no query loaded', async () => { + await savedQueryManagementComponent.saveNewQueryMissingOrFail(); + }); + + it('does not allow saving changes to saved query from the saved query management component', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + await queryBar.setQuery('response:404'); + await savedQueryManagementComponent.updateCurrentlyLoadedQueryMissingOrFail(); + }); + + it('does not allow deleting a saved query from the saved query management component', async () => { + await savedQueryManagementComponent.deleteSavedQueryMissingOrFail('OKJpgs'); + }); + + it('allows clearing the currently loaded saved query', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); + }); + }, + }; +} diff --git a/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts b/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts index 0bf6f6fad2a7..ef2af3bdf955 100644 --- a/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts +++ b/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts @@ -29,6 +29,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const queryBar = getService('queryBar'); const savedQueryManagementComponent = getService('savedQueryManagementComponent'); + // more tests are in x-pack/test/functional/apps/saved_query_management/feature_controls/security.ts + describe('visualize feature controls security', () => { before(async () => { await kibanaServer.savedObjects.cleanStandardList();