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

<img width="600" alt="Screenshot 2023-09-21 at 15 26 25"
src="6d53548e-5c5a-4d6d-a86a-1e639cb77202">

### 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 <matthias.wilhelm@elastic.co>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Stratoula Kalafateli <efstratia.kalafateli@elastic.co>
This commit is contained in:
Julia Rechkunova 2023-09-29 11:52:39 +02:00 committed by GitHub
parent d0a0a1f9e6
commit 7fa04e92bc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 798 additions and 183 deletions

View file

@ -310,6 +310,7 @@ enabled:
- x-pack/test/functional/apps/reporting_management/config.ts - x-pack/test/functional/apps/reporting_management/config.ts
- x-pack/test/functional/apps/rollup_job/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_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/security/config.ts
- x-pack/test/functional/apps/snapshot_restore/config.ts - x-pack/test/functional/apps/snapshot_restore/config.ts
- x-pack/test/functional/apps/spaces/config.ts - x-pack/test/functional/apps/spaces/config.ts

1
.github/CODEOWNERS vendored
View file

@ -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/examples/search_examples @elastic/kibana-data-discovery
/x-pack/test/functional/apps/data_views @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/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/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/search_sessions_integration @elastic/kibana-data-discovery
/x-pack/test/stack_functional_integration/apps/ccs/ccs_discover.js @elastic/kibana-data-discovery /x-pack/test/stack_functional_integration/apps/ccs/ccs_discover.js @elastic/kibana-data-discovery

View file

@ -97,7 +97,7 @@ export const App = ({
showSearchBar={true} showSearchBar={true}
indexPatterns={[dataView]} indexPatterns={[dataView]}
useDefaultBehaviors={true} useDefaultBehaviors={true}
showSaveQuery={true} saveQueryMenuVisibility="allowed_by_app_privilege" // allowed only for this example app, use `globally_managed` by default
/> />
<EuiPageTemplate.Section> <EuiPageTemplate.Section>
<EuiText> <EuiText>

View file

@ -68,7 +68,7 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr
navigation: { TopNavMenu }, navigation: { TopNavMenu },
embeddable: { getStateTransfer }, embeddable: { getStateTransfer },
initializerContext: { allowByValueEmbeddables }, initializerContext: { allowByValueEmbeddables },
dashboardCapabilities: { saveQuery: showSaveQuery, showWriteControls }, dashboardCapabilities: { saveQuery: allowSaveQuery, showWriteControls },
} = pluginServices.getServices(); } = pluginServices.getServices();
const isLabsEnabled = uiSettings.get(UI_SETTINGS.ENABLE_LABS_UI); const isLabsEnabled = uiSettings.get(UI_SETTINGS.ENABLE_LABS_UI);
const { setHeaderActionMenu, onAppLeave } = useDashboardMountContext(); const { setHeaderActionMenu, onAppLeave } = useDashboardMountContext();
@ -298,7 +298,7 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr
useDefaultBehaviors={true} useDefaultBehaviors={true}
savedQueryId={savedQueryId} savedQueryId={savedQueryId}
indexPatterns={allDataViews} indexPatterns={allDataViews}
showSaveQuery={showSaveQuery} saveQueryMenuVisibility={allowSaveQuery ? 'allowed_by_app_privilege' : 'globally_managed'}
appName={LEGACY_DASHBOARD_APP_ID} appName={LEGACY_DASHBOARD_APP_ID}
visible={viewMode !== ViewMode.PRINT} visible={viewMode !== ViewMode.PRINT}
setMenuMountPoint={embedSettings || fullScreenMode ? undefined : setHeaderActionMenu} setMenuMountPoint={embedSettings || fullScreenMode ? undefined : setHeaderActionMenu}

View file

@ -88,7 +88,7 @@ describe('ContextApp test', () => {
showSearchBar: true, showSearchBar: true,
showQueryInput: false, showQueryInput: false,
showFilterBar: true, showFilterBar: true,
showSaveQuery: false, saveQueryMenuVisibility: 'hidden' as const,
showDatePicker: false, showDatePicker: false,
indexPatterns: [dataViewMock], indexPatterns: [dataViewMock],
useDefaultBehaviors: true, useDefaultBehaviors: true,

View file

@ -207,7 +207,7 @@ export const ContextApp = ({ dataView, anchorId, referrer }: ContextAppProps) =>
showSearchBar: true, showSearchBar: true,
showQueryInput: false, showQueryInput: false,
showFilterBar: true, showFilterBar: true,
showSaveQuery: false, saveQueryMenuVisibility: 'hidden' as const,
showDatePicker: false, showDatePicker: false,
indexPatterns: [dataView], indexPatterns: [dataView],
useDefaultBehaviors: true, useDefaultBehaviors: true,

View file

@ -53,8 +53,20 @@ jest.mock('../../../../customizations', () => ({
useDiscoverCustomization: jest.fn(), useDiscoverCustomization: jest.fn(),
})); }));
function getProps(savePermissions = true): DiscoverTopNavProps { const mockDefaultCapabilities = {
mockDiscoverService.capabilities.discover!.save = savePermissions; discover: { save: true },
} as unknown as typeof mockDiscoverService.capabilities;
function getProps(
{
capabilities,
}: {
capabilities?: Partial<typeof mockDiscoverService.capabilities>;
} = { capabilities: mockDefaultCapabilities }
): DiscoverTopNavProps {
if (capabilities) {
mockDiscoverService.capabilities = capabilities as typeof mockDiscoverService.capabilities;
}
const stateContainer = getDiscoverStateMock({ isTimeBased: true }); const stateContainer = getDiscoverStateMock({ isTimeBased: true });
stateContainer.internalState.transitions.setDataView(dataViewMock); 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', () => { 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( const component = mountWithIntl(
<DiscoverMainProvider value={props.stateContainer}> <DiscoverMainProvider value={props.stateContainer}>
<DiscoverTopNav {...props} /> <DiscoverTopNav {...props} />
@ -105,7 +117,7 @@ describe('Discover topnav component', () => {
}); });
test('generated config of TopNavMenu config is correct when no discover save permissions are assigned', () => { 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( const component = mountWithIntl(
<DiscoverMainProvider value={props.stateContainer}> <DiscoverMainProvider value={props.stateContainer}>
<DiscoverTopNav {...props} /> <DiscoverTopNav {...props} />
@ -116,6 +128,32 @@ describe('Discover topnav component', () => {
expect(topMenuConfig).toEqual(['new', 'open', 'share', 'inspect']); 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(
<DiscoverMainProvider value={props.stateContainer}>
<DiscoverTopNav {...props} />
</DiscoverMainProvider>
);
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(
<DiscoverMainProvider value={props.stateContainer}>
<DiscoverTopNav {...props} />
</DiscoverMainProvider>
);
const statefulSearchBar = component.find(
mockDiscoverService.navigation.ui.AggregateQueryTopNavMenu
);
expect(statefulSearchBar.props().saveQueryMenuVisibility).toBe('globally_managed');
});
describe('top nav customization', () => { describe('top nav customization', () => {
it('should call getMenuItems', () => { it('should call getMenuItems', () => {
mockUseCustomizations = true; mockUseCustomizations = true;

View file

@ -220,7 +220,9 @@ export const DiscoverTopNav = ({
savedQueryId={savedQuery} savedQueryId={savedQuery}
screenTitle={savedSearch.title} screenTitle={savedSearch.title}
showDatePicker={showDatePicker} showDatePicker={showDatePicker}
showSaveQuery={!isPlainRecord && Boolean(services.capabilities.discover.saveQuery)} saveQueryMenuVisibility={
services.capabilities.discover.saveQuery ? 'allowed_by_app_privilege' : 'globally_managed'
}
showSearchBar={true} showSearchBar={true}
useDefaultBehaviors={true} useDefaultBehaviors={true}
dataViewPickerOverride={ dataViewPickerOverride={

View file

@ -20,7 +20,7 @@ import classNames from 'classnames';
import { MountPoint } from '@kbn/core/public'; import { MountPoint } from '@kbn/core/public';
import { MountPointPortal } from '@kbn/kibana-react-plugin/public'; import { MountPointPortal } from '@kbn/kibana-react-plugin/public';
import { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-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 { AggregateQuery, Query } from '@kbn/es-query';
import { TopNavMenuData } from './top_nav_menu_data'; import { TopNavMenuData } from './top_nav_menu_data';
import { TopNavMenuItem } from './top_nav_menu_item'; import { TopNavMenuItem } from './top_nav_menu_item';
@ -30,9 +30,10 @@ type Badge = EuiBadgeProps & {
toolTipProps?: Partial<EuiToolTipProps>; toolTipProps?: Partial<EuiToolTipProps>;
}; };
export type TopNavMenuProps<QT extends Query | AggregateQuery = Query> = export type TopNavMenuProps<QT extends Query | AggregateQuery = Query> = Omit<
StatefulSearchBarProps<QT> & StatefulSearchBarProps<QT>,
Omit<SearchBarProps<QT>, 'kibana' | 'intl' | 'timeHistory'> & { 'kibana' | 'intl' | 'timeHistory'
> & {
config?: TopNavMenuData[]; config?: TopNavMenuData[];
badges?: Badge[]; badges?: Badge[];
showSearchBar?: boolean; showSearchBar?: boolean;

View file

@ -174,11 +174,20 @@ const services = {
}, },
}; };
const defaultCapabilities = {
savedObjectsManagement: {
edit: true,
},
};
setIndexPatterns({ setIndexPatterns({
get: () => Promise.resolve(mockIndexPatterns[0]), get: () => Promise.resolve(mockIndexPatterns[0]),
} as unknown as DataViewsContract); } as unknown as DataViewsContract);
function wrapSearchBarInContext(testProps: SearchBarProps<Query>) { function wrapSearchBarInContext(
testProps: Partial<SearchBarProps<Query>>,
capabilities: typeof defaultCapabilities = defaultCapabilities
) {
const defaultOptions = { const defaultOptions = {
appName: 'test', appName: 'test',
timeHistory: mockTimeHistory, timeHistory: mockTimeHistory,
@ -197,9 +206,16 @@ function wrapSearchBarInContext(testProps: SearchBarProps<Query>) {
onFiltersUpdated: action('onFiltersUpdated'), onFiltersUpdated: action('onFiltersUpdated'),
} as unknown as SearchBarProps<Query>; } as unknown as SearchBarProps<Query>;
const kbnServices = {
...services,
application: {
capabilities,
},
};
return ( return (
<I18nProvider> <I18nProvider>
<KibanaContextProvider services={services}> <KibanaContextProvider services={kbnServices}>
<SearchBar<Query> {...defaultOptions} {...testProps} /> <SearchBar<Query> {...defaultOptions} {...testProps} />
</KibanaContextProvider> </KibanaContextProvider>
</I18nProvider> </I18nProvider>
@ -219,7 +235,7 @@ storiesOf('SearchBar', module)
}, },
onChangeDataView: action('onChangeDataView'), onChangeDataView: action('onChangeDataView'),
}, },
} as SearchBarProps) })
) )
.add('with dataviewPicker enhanced', () => .add('with dataviewPicker enhanced', () =>
wrapSearchBarInContext({ wrapSearchBarInContext({
@ -234,41 +250,56 @@ storiesOf('SearchBar', module)
onAddField: action('onAddField'), onAddField: action('onAddField'),
onDataViewCreated: action('onDataViewCreated'), onDataViewCreated: action('onDataViewCreated'),
}, },
} as SearchBarProps) })
) )
.add('with filterBar off', () => .add('with filterBar off', () =>
wrapSearchBarInContext({ wrapSearchBarInContext({
showFilterBar: false, showFilterBar: false,
} as SearchBarProps) })
) )
.add('with query input off', () => .add('with query input off', () =>
wrapSearchBarInContext({ wrapSearchBarInContext({
showQueryInput: false, showQueryInput: false,
} as SearchBarProps) })
) )
.add('with date picker off', () => .add('with date picker off', () =>
wrapSearchBarInContext({ wrapSearchBarInContext({
showDatePicker: false, 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', () => .add('with the default date picker auto refresh interval on', () =>
wrapSearchBarInContext({ wrapSearchBarInContext({
showDatePicker: true, showDatePicker: true,
onRefreshChange: action('onRefreshChange'), onRefreshChange: action('onRefreshChange'),
} as SearchBarProps) })
) )
.add('with the default date picker auto refresh interval off', () => .add('with the default date picker auto refresh interval off', () =>
wrapSearchBarInContext({ wrapSearchBarInContext({
showDatePicker: true, showDatePicker: true,
isAutoRefreshDisabled: true, isAutoRefreshDisabled: true,
} as SearchBarProps) })
) )
.add('with only the date picker on', () => .add('with only the date picker on', () =>
wrapSearchBarInContext({ wrapSearchBarInContext({
showDatePicker: true, showDatePicker: true,
showFilterBar: false, showFilterBar: false,
showQueryInput: false, showQueryInput: false,
} as SearchBarProps) })
) )
.add('with additional filters used for suggestions', () => .add('with additional filters used for suggestions', () =>
wrapSearchBarInContext({ wrapSearchBarInContext({
@ -470,12 +501,12 @@ storiesOf('SearchBar', module)
/> />
), ),
showQueryInput: true, showQueryInput: true,
} as SearchBarProps) })
) )
.add('without switch query language', () => .add('without switch query language', () =>
wrapSearchBarInContext({ wrapSearchBarInContext({
disableQueryLanguageSwitcher: true, disableQueryLanguageSwitcher: true,
} as SearchBarProps) })
) )
.add('show only query bar without submit', () => .add('show only query bar without submit', () =>
wrapSearchBarInContext({ wrapSearchBarInContext({
@ -484,7 +515,7 @@ storiesOf('SearchBar', module)
showAutoRefreshOnly: false, showAutoRefreshOnly: false,
showQueryInput: true, showQueryInput: true,
showSubmitButton: false, showSubmitButton: false,
} as SearchBarProps) })
) )
.add('show only datepicker without submit', () => .add('show only datepicker without submit', () =>
wrapSearchBarInContext({ wrapSearchBarInContext({
@ -493,7 +524,7 @@ storiesOf('SearchBar', module)
showAutoRefreshOnly: false, showAutoRefreshOnly: false,
showQueryInput: false, showQueryInput: false,
showSubmitButton: false, showSubmitButton: false,
} as SearchBarProps) })
) )
.add('show only query bar and timepicker without submit', () => .add('show only query bar and timepicker without submit', () =>
wrapSearchBarInContext({ wrapSearchBarInContext({
@ -502,7 +533,7 @@ storiesOf('SearchBar', module)
showAutoRefreshOnly: false, showAutoRefreshOnly: false,
showQueryInput: true, showQueryInput: true,
showSubmitButton: false, showSubmitButton: false,
} as SearchBarProps) })
) )
.add('with filter bar on but pinning option is hidden from menus', () => .add('with filter bar on but pinning option is hidden from menus', () =>
wrapSearchBarInContext({ wrapSearchBarInContext({
@ -621,7 +652,7 @@ storiesOf('SearchBar', module)
onChangeDataView: action('onChangeDataView'), onChangeDataView: action('onChangeDataView'),
}, },
isDisabled: true, isDisabled: true,
} as SearchBarProps) })
) )
.add('no submit button', () => .add('no submit button', () =>
wrapSearchBarInContext({ wrapSearchBarInContext({
@ -635,7 +666,7 @@ storiesOf('SearchBar', module)
onChangeDataView: action('onChangeDataView'), onChangeDataView: action('onChangeDataView'),
}, },
showSubmitButton: false, showSubmitButton: false,
} as SearchBarProps) })
) )
.add('submit button always as icon', () => .add('submit button always as icon', () =>
wrapSearchBarInContext({ wrapSearchBarInContext({
@ -649,7 +680,7 @@ storiesOf('SearchBar', module)
onChangeDataView: action('onChangeDataView'), onChangeDataView: action('onChangeDataView'),
}, },
submitButtonStyle: 'iconOnly', submitButtonStyle: 'iconOnly',
} as SearchBarProps) })
) )
.add('submit button always as a full button', () => .add('submit button always as a full button', () =>
wrapSearchBarInContext({ wrapSearchBarInContext({
@ -663,5 +694,5 @@ storiesOf('SearchBar', module)
onChangeDataView: action('onChangeDataView'), onChangeDataView: action('onChangeDataView'),
}, },
submitButtonStyle: 'full', submitButtonStyle: 'full',
} as SearchBarProps) })
); );

View file

@ -21,6 +21,7 @@ import { useFilterManager } from './lib/use_filter_manager';
import { useTimefilter } from './lib/use_timefilter'; import { useTimefilter } from './lib/use_timefilter';
import { useSavedQuery } from './lib/use_saved_query'; import { useSavedQuery } from './lib/use_saved_query';
import { useQueryStringManager } from './lib/use_query_string_manager'; import { useQueryStringManager } from './lib/use_query_string_manager';
import { type SavedQueryMenuVisibility, canShowSavedQuery } from './lib/can_show_saved_query';
import type { UnifiedSearchPublicPluginStart } from '../types'; import type { UnifiedSearchPublicPluginStart } from '../types';
export interface StatefulSearchBarDeps { export interface StatefulSearchBarDeps {
@ -32,11 +33,14 @@ export interface StatefulSearchBarDeps {
unifiedSearch: Omit<UnifiedSearchPublicPluginStart, 'ui'>; unifiedSearch: Omit<UnifiedSearchPublicPluginStart, 'ui'>;
} }
export type StatefulSearchBarProps<QT extends Query | AggregateQuery = Query> = export type StatefulSearchBarProps<QT extends Query | AggregateQuery = Query> = Omit<
SearchBarOwnProps<QT> & { SearchBarOwnProps<QT>,
'showSaveQuery'
> & {
appName: string; appName: string;
useDefaultBehaviors?: boolean; useDefaultBehaviors?: boolean;
savedQueryId?: string; savedQueryId?: string;
saveQueryMenuVisibility?: SavedQueryMenuVisibility;
onSavedQueryIdChange?: (savedQueryId?: string) => void; onSavedQueryIdChange?: (savedQueryId?: string) => void;
onFiltersUpdated?: (filters: Filter[]) => void; onFiltersUpdated?: (filters: Filter[]) => void;
}; };
@ -194,6 +198,12 @@ export function createSearchBar({
); );
}, [query, timeRange, useDefaultBehaviors]); }, [query, timeRange, useDefaultBehaviors]);
const showSaveQuery = canShowSavedQuery({
saveQueryMenuVisibility: props.saveQueryMenuVisibility,
query,
core,
});
return ( return (
<KibanaContextProvider <KibanaContextProvider
services={{ services={{
@ -212,7 +222,7 @@ export function createSearchBar({
showFilterBar={props.showFilterBar} showFilterBar={props.showFilterBar}
showQueryMenu={props.showQueryMenu} showQueryMenu={props.showQueryMenu}
showQueryInput={props.showQueryInput} showQueryInput={props.showQueryInput}
showSaveQuery={props.showSaveQuery} showSaveQuery={showSaveQuery}
showSubmitButton={props.showSubmitButton} showSubmitButton={props.showSubmitButton}
submitButtonStyle={props.submitButtonStyle} submitButtonStyle={props.submitButtonStyle}
isDisabled={props.isDisabled} isDisabled={props.isDisabled}

View file

@ -0,0 +1,107 @@
/*
* 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 { coreMock } from '@kbn/core/public/mocks';
import { canShowSavedQuery } from './can_show_saved_query';
const core = coreMock.createStart();
function getCore(saveQueryGloballyAllowed: boolean): typeof core {
return {
...core,
application: {
...core.application,
capabilities: {
...core.application.capabilities,
savedQueryManagement: { saveQuery: saveQueryGloballyAllowed },
},
},
};
}
const coreWithoutGlobalPrivilege = getCore(false);
const coreWithGlobalPrivilege = getCore(true);
const kqlQuery = {
query: 'response:200',
language: 'kuery',
};
const esqlQuery = {
esql: 'from test | limit 10',
};
describe('canShowSaveQuery', () => {
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);
});
});

View file

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

View file

@ -68,7 +68,7 @@ export interface SearchBarOwnProps<QT extends AggregateQuery | Query = Query> {
dateRangeTo?: string; dateRangeTo?: string;
// Query bar - should be in SearchBarInjectedDeps // Query bar - should be in SearchBarInjectedDeps
query?: QT | Query; query?: QT | Query;
// Show when user has privileges to save // Show when user has privileges to save. See `canShowSavedQuery(...)` lib.
showSaveQuery?: boolean; showSaveQuery?: boolean;
savedQuery?: SavedQuery; savedQuery?: SavedQuery;
onQueryChange?: (payload: { dateRange: TimeRange; query?: QT | Query }) => void; onQueryChange?: (payload: { dateRange: TimeRange; query?: QT | Query }) => void;

View file

@ -40,6 +40,7 @@
"@kbn/text-based-languages", "@kbn/text-based-languages",
"@kbn/text-based-editor", "@kbn/text-based-editor",
"@kbn/core-doc-links-browser", "@kbn/core-doc-links-browser",
"@kbn/core-lifecycle-browser",
], ],
"exclude": [ "exclude": [
"target/**/*", "target/**/*",

View file

@ -307,7 +307,9 @@ const TopNav = ({
showDatePicker={showDatePicker()} showDatePicker={showDatePicker()}
showFilterBar={showFilterBar} showFilterBar={showFilterBar}
showQueryInput={showQueryInput} showQueryInput={showQueryInput}
showSaveQuery={Boolean(services.visualizeCapabilities.saveQuery)} saveQueryMenuVisibility={
services.visualizeCapabilities.saveQuery ? 'allowed_by_app_privilege' : 'globally_managed'
}
dataViewPickerComponentProps={ dataViewPickerComponentProps={
shouldShowDataViewPicker && vis.data.indexPattern shouldShowDataViewPicker && vis.data.indexPattern
? { ? {
@ -346,7 +348,7 @@ const TopNav = ({
setMenuMountPoint={setHeaderActionMenu} setMenuMountPoint={setHeaderActionMenu}
indexPatterns={indexPatterns} indexPatterns={indexPatterns}
showSearchBar showSearchBar
showSaveQuery={false} saveQueryMenuVisibility="hidden"
showDatePicker={false} showDatePicker={false}
showQueryInput={false} showQueryInput={false}
/> />

View file

@ -48,7 +48,7 @@ export const FindingsSearchBar = ({
showFilterBar={true} showFilterBar={true}
showQueryInput={true} showQueryInput={true}
showDatePicker={false} showDatePicker={false}
showSaveQuery={false} saveQueryMenuVisibility="hidden"
isLoading={loading} isLoading={loading}
indexPatterns={[dataView]} indexPatterns={[dataView]}
onQuerySubmit={setQuery} onQuerySubmit={setQuery}

View file

@ -177,6 +177,10 @@ Array [
"id": "savedObjectsManagement", "id": "savedObjectsManagement",
"subFeatures": undefined, "subFeatures": undefined,
}, },
Object {
"id": "savedQueryManagement",
"subFeatures": undefined,
},
] ]
`; `;
@ -469,6 +473,10 @@ Array [
"id": "savedObjectsManagement", "id": "savedObjectsManagement",
"subFeatures": undefined, "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`] = ` exports[`buildOSSFeatures with a basic license returns the visualize feature augmented with appropriate sub feature privileges 1`] = `
Array [ Array [
Object { 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`] = ` exports[`buildOSSFeatures with a enterprise license returns the visualize feature augmented with appropriate sub feature privileges 1`] = `
Array [ Array [
Object { Object {

View file

@ -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[]; ] as KibanaFeatureConfig[];
}; };

View file

@ -69,6 +69,7 @@ describe('Features Plugin', () => {
"filesManagement", "filesManagement",
"filesSharedImage", "filesSharedImage",
"savedObjectsManagement", "savedObjectsManagement",
"savedQueryManagement",
] ]
`); `);
}); });

View file

@ -57,7 +57,11 @@ export const UnifiedSearchBar = () => {
defaultMessage: 'Search hosts (E.g. cloud.provider:gcp AND system.load.1 > 0.5)', defaultMessage: 'Search hosts (E.g. cloud.provider:gcp AND system.load.1 > 0.5)',
})} })}
onQuerySubmit={handleRefresh} onQuerySubmit={handleRefresh}
showSaveQuery={Boolean(application?.capabilities?.visualize?.saveQuery)} saveQueryMenuVisibility={
application?.capabilities?.visualize?.saveQuery
? 'allowed_by_app_privilege'
: 'globally_managed'
}
showDatePicker showDatePicker
showFilterBar showFilterBar
showQueryInput showQueryInput

View file

@ -930,6 +930,38 @@ describe('Lens App', () => {
instance.update(); instance.update();
expect(instance.find(SavedObjectSaveModal).prop('showCopyOnSave')).toEqual(false); 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 }); await mountWith({ services });
expect(services.navigation.ui.AggregateQueryTopNavMenu).toHaveBeenCalledWith( 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({}); const { instance, services } = await mountWith({});
expect(services.navigation.ui.AggregateQueryTopNavMenu).toHaveBeenCalledWith( expect(services.navigation.ui.AggregateQueryTopNavMenu).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
showSaveQuery: true, saveQueryMenuVisibility: 'allowed_by_app_privilege',
savedQuery: undefined, savedQuery: undefined,
onSaved: expect.any(Function), onSaved: expect.any(Function),
onSavedQueryUpdated: expect.any(Function), onSavedQueryUpdated: expect.any(Function),

View file

@ -1054,7 +1054,11 @@ export const LensTopNavMenu = ({
<AggregateQueryTopNavMenu <AggregateQueryTopNavMenu
setMenuMountPoint={setHeaderActionMenu} setMenuMountPoint={setHeaderActionMenu}
config={topNavConfig} config={topNavConfig}
showSaveQuery={Boolean(application.capabilities.visualize.saveQuery)} saveQueryMenuVisibility={
application.capabilities.visualize.saveQuery
? 'allowed_by_app_privilege'
: 'globally_managed'
}
savedQuery={savedQuery} savedQuery={savedQuery}
onQuerySubmit={onQuerySubmitWrapped} onQuerySubmit={onQuerySubmitWrapped}
onSaved={onSavedWrapped} onSaved={onSavedWrapped}

View file

@ -522,7 +522,9 @@ export class MapApp extends React.Component<Props, State> {
showSearchBar={true} showSearchBar={true}
showFilterBar={true} showFilterBar={true}
showDatePicker={true} showDatePicker={true}
showSaveQuery={!!getMapsCapabilities().saveQuery} saveQueryMenuVisibility={
getMapsCapabilities().saveQuery ? 'allowed_by_app_privilege' : 'globally_managed'
}
savedQuery={this.state.savedQuery} savedQuery={this.state.savedQuery}
onSaved={this._updateStateFromSavedQuery} onSaved={this._updateStateFromSavedQuery}
onSavedQueryUpdated={this._updateStateFromSavedQuery} onSavedQueryUpdated={this._updateStateFromSavedQuery}

View file

@ -329,7 +329,7 @@ export const SearchBarComponent = memo<SiemSearchBarProps & PropsFromRedux>(
showFilterBar={!hideFilterBar} showFilterBar={!hideFilterBar}
showDatePicker={true} showDatePicker={true}
showQueryInput={!hideQueryInput} showQueryInput={!hideQueryInput}
showSaveQuery={true} saveQueryMenuVisibility="allowed_by_app_privilege"
dataTestSubj={dataTestSubj} dataTestSubj={dataTestSubj}
/> />
</div> </div>

View file

@ -328,7 +328,7 @@ export const SearchSourceExpressionForm = (props: SearchSourceExpressionFormProp
onClearSavedQuery={onClearSavedQuery} onClearSavedQuery={onClearSavedQuery}
onSavedQueryUpdated={onSavedQuery} onSavedQueryUpdated={onSavedQuery}
onSaved={onSavedQuery} onSaved={onSavedQuery}
showSaveQuery saveQueryMenuVisibility="allowed_by_app_privilege"
showQueryInput showQueryInput
showFilterBar showFilterBar
showDatePicker={false} showDatePicker={false}

View file

@ -101,7 +101,7 @@ export function AlertsSearchBar({
onRefresh={onRefresh} onRefresh={onRefresh}
showDatePicker={showDatePicker} showDatePicker={showDatePicker}
showQueryInput={true} showQueryInput={true}
showSaveQuery={true} saveQueryMenuVisibility="allowed_by_app_privilege"
showSubmitButton={showSubmitButton} showSubmitButton={showSubmitButton}
submitOnBlur={submitOnBlur} submitOnBlur={submitOnBlur}
onQueryChange={onSearchQueryChange} onQueryChange={onSearchQueryChange}

View file

@ -88,7 +88,7 @@ export const KqlSearchBar = React.memo<KqlSearchBarProps>(({ onQuerySubmit }) =>
indexPatterns={loading || error ? NO_INDEX_PATTERNS : dataView} indexPatterns={loading || error ? NO_INDEX_PATTERNS : dataView}
showAutoRefreshOnly={false} showAutoRefreshOnly={false}
showDatePicker={false} showDatePicker={false}
showSaveQuery={false} saveQueryMenuVisibility="hidden"
showQueryInput={true} showQueryInput={true}
showQueryMenu={false} showQueryMenu={false}
showFilterBar={true} showFilterBar={true}

View file

@ -110,6 +110,7 @@ export default function ({ getService }: FtrProviderContext) {
'observabilityAIAssistant', 'observabilityAIAssistant',
'observabilityCases', 'observabilityCases',
'savedObjectsManagement', 'savedObjectsManagement',
'savedQueryManagement',
'savedObjectsTagging', 'savedObjectsTagging',
'ml', 'ml',
'apm', 'apm',

View file

@ -82,6 +82,7 @@ export default function ({ getService }: FtrProviderContext) {
advancedSettings: ['all', 'read', 'minimal_all', 'minimal_read'], advancedSettings: ['all', 'read', 'minimal_all', 'minimal_read'],
indexPatterns: ['all', 'read', 'minimal_all', 'minimal_read'], indexPatterns: ['all', 'read', 'minimal_all', 'minimal_read'],
savedObjectsManagement: ['all', 'read', 'minimal_all', 'minimal_read'], savedObjectsManagement: ['all', 'read', 'minimal_all', 'minimal_read'],
savedQueryManagement: ['all', 'minimal_all'],
osquery: [ osquery: [
'all', 'all',
'read', 'read',

View file

@ -27,6 +27,7 @@ export default function ({ getService }: FtrProviderContext) {
advancedSettings: ['all', 'read', 'minimal_all', 'minimal_read'], advancedSettings: ['all', 'read', 'minimal_all', 'minimal_read'],
indexPatterns: ['all', 'read', 'minimal_all', 'minimal_read'], indexPatterns: ['all', 'read', 'minimal_all', 'minimal_read'],
savedObjectsManagement: ['all', 'read', 'minimal_all', 'minimal_read'], savedObjectsManagement: ['all', 'read', 'minimal_all', 'minimal_read'],
savedQueryManagement: ['all', 'minimal_all'],
savedObjectsTagging: ['all', 'read', 'minimal_all', 'minimal_read'], savedObjectsTagging: ['all', 'read', 'minimal_all', 'minimal_read'],
graph: ['all', 'read', 'minimal_all', 'minimal_read'], graph: ['all', 'read', 'minimal_all', 'minimal_read'],
maps: ['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'], filesManagement: ['all', 'read', 'minimal_all', 'minimal_read'],
filesSharedImage: ['all', 'read', 'minimal_all', 'minimal_read'], filesSharedImage: ['all', 'read', 'minimal_all', 'minimal_read'],
savedObjectsManagement: ['all', 'read', 'minimal_all', 'minimal_read'], savedObjectsManagement: ['all', 'read', 'minimal_all', 'minimal_read'],
savedQueryManagement: ['all', 'minimal_all'],
osquery: [ osquery: [
'all', 'all',
'read', 'read',

View file

@ -33,6 +33,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
shouldLoginIfPrompted: false, shouldLoginIfPrompted: false,
}; };
// more tests are in x-pack/test/functional/apps/saved_query_management/feature_controls/security.ts
describe('dashboard feature controls security', () => { describe('dashboard feature controls security', () => {
before(async () => { before(async () => {
await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/logstash_functional'); await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/logstash_functional');

View file

@ -7,8 +7,11 @@
import expect from '@kbn/expect'; import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../ftr_provider_context'; 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 esArchiver = getService('esArchiver');
const esSupertest = getService('esSupertest'); const esSupertest = getService('esSupertest');
const dataGrid = getService('dataGrid'); const dataGrid = getService('dataGrid');
@ -31,8 +34,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
]); ]);
const testSubjects = getService('testSubjects'); const testSubjects = getService('testSubjects');
const appsMenu = getService('appsMenu'); const appsMenu = getService('appsMenu');
const queryBar = getService('queryBar');
const savedQueryManagementComponent = getService('savedQueryManagementComponent');
const kibanaServer = getService('kibanaServer'); const kibanaServer = getService('kibanaServer');
const logstashIndexName = 'logstash-2015.09.22'; const logstashIndexName = 'logstash-2015.09.22';
@ -40,6 +41,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
await PageObjects.timePicker.setDefaultAbsoluteRange(); 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', () => { describe('discover feature controls security', () => {
before(async () => { before(async () => {
await kibanaServer.importExport.load( await kibanaServer.importExport.load(
@ -129,53 +132,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
await PageObjects.share.clickShareTopNavButton(); await PageObjects.share.clickShareTopNavButton();
}); });
it('allows saving via the saved query management component popover with no saved query loaded', async () => { savedQuerySecurityUtils.shouldAllowSavingQueries();
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');
});
}); });
describe('global discover read-only privileges', () => { describe('global discover read-only privileges', () => {
@ -245,33 +202,10 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
await PageObjects.share.clickShareTopNavButton(); await PageObjects.share.clickShareTopNavButton();
}); });
it('allows loading a saved query via the saved query management component', async () => { savedQuerySecurityUtils.shouldDisallowSavingButAllowLoadingSavedQueries();
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 () => { describe('discover read-only privileges with url_create', () => {
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();
});
});
describe('global discover read-only privileges with url_create', () => {
before(async () => { before(async () => {
await security.role.create('global_discover_read_url_create_role', { await security.role.create('global_discover_read_url_create_role', {
elasticsearch: { elasticsearch: {
@ -338,30 +272,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
await PageObjects.share.clickShareTopNavButton(); await PageObjects.share.clickShareTopNavButton();
}); });
it('allows loading a saved query via the saved query management component', async () => { savedQuerySecurityUtils.shouldDisallowSavingButAllowLoadingSavedQueries();
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();
});
}); });
describe('discover and visualize privileges', () => { describe('discover and visualize privileges', () => {

View file

@ -17,6 +17,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
const queryBar = getService('queryBar'); const queryBar = getService('queryBar');
const savedQueryManagementComponent = getService('savedQueryManagementComponent'); 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', () => { describe('maps security feature controls', () => {
after(async () => { after(async () => {
// logout, so the other tests don't accidentally run as the custom users we're testing below // logout, so the other tests don't accidentally run as the custom users we're testing below

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { 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('.')],
};
}

View file

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

View file

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

View file

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

View file

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

View file

@ -29,6 +29,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
const queryBar = getService('queryBar'); const queryBar = getService('queryBar');
const savedQueryManagementComponent = getService('savedQueryManagementComponent'); 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', () => { describe('visualize feature controls security', () => {
before(async () => { before(async () => {
await kibanaServer.savedObjects.cleanStandardList(); await kibanaServer.savedObjects.cleanStandardList();