diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md index f576d795b93a52..d2e7ef9db05e8e 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md @@ -126,6 +126,7 @@ | [noSearchSessionStorageCapabilityMessage](./kibana-plugin-plugins-data-public.nosearchsessionstoragecapabilitymessage.md) | Message to display in case storing session session is disabled due to turned off capability | | [parseSearchSourceJSON](./kibana-plugin-plugins-data-public.parsesearchsourcejson.md) | | | [QueryStringInput](./kibana-plugin-plugins-data-public.querystringinput.md) | | +| [SEARCH\_SESSIONS\_MANAGEMENT\_ID](./kibana-plugin-plugins-data-public.search_sessions_management_id.md) | | | [search](./kibana-plugin-plugins-data-public.search.md) | | | [SearchBar](./kibana-plugin-plugins-data-public.searchbar.md) | | | [syncQueryStateWithUrl](./kibana-plugin-plugins-data-public.syncquerystatewithurl.md) | Helper to setup syncing of global data with the URL | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.search_sessions_management_id.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.search_sessions_management_id.md new file mode 100644 index 00000000000000..ad16d21403a984 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.search_sessions_management_id.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SEARCH\_SESSIONS\_MANAGEMENT\_ID](./kibana-plugin-plugins-data-public.search_sessions_management_id.md) + +## SEARCH\_SESSIONS\_MANAGEMENT\_ID variable + +Signature: + +```typescript +SEARCH_SESSIONS_MANAGEMENT_ID = "search_sessions" +``` diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 17533eec0a0fa2..83a248ee2c3dee 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -381,6 +381,7 @@ export { TimeoutErrorMode, PainlessError, noSearchSessionStorageCapabilityMessage, + SEARCH_SESSIONS_MANAGEMENT_ID, } from './search'; export type { diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 408573e12eba54..95c849ce74248b 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -2238,6 +2238,11 @@ export const search: { tabifyGetColumns: typeof tabifyGetColumns; }; +// Warning: (ae-missing-release-tag) "SEARCH_SESSIONS_MANAGEMENT_ID" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const SEARCH_SESSIONS_MANAGEMENT_ID = "search_sessions"; + // Warning: (ae-missing-release-tag) "SearchBar" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -2601,23 +2606,23 @@ export const UI_SETTINGS: { // src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:396:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:396:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:396:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:396:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:398:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:399:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:408:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:409:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:410:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:411:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:415:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:416:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:419:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:420:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:423:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:397:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:397:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:397:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:397:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:399:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:400:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:409:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:410:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:411:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:412:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:416:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:417:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:420:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:421:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:424:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:34:5 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/search/session/session_service.ts:41:5 - (ae-forgotten-export) The symbol "UrlGeneratorStateMapping" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/search/session/session_service.ts:42:5 - (ae-forgotten-export) The symbol "UrlGeneratorStateMapping" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/src/plugins/data/public/search/index.ts b/src/plugins/data/public/search/index.ts index 31a94d69ddf023..b1e0bc490823a3 100644 --- a/src/plugins/data/public/search/index.ts +++ b/src/plugins/data/public/search/index.ts @@ -38,6 +38,7 @@ export { SessionsClient, ISessionsClient, noSearchSessionStorageCapabilityMessage, + SEARCH_SESSIONS_MANAGEMENT_ID, } from './session'; export { getEsPreference } from './es_search'; diff --git a/src/plugins/data/public/search/search_interceptor.test.ts b/src/plugins/data/public/search/search_interceptor.test.ts index 02d5a19c31743f..f890fdc3e30a34 100644 --- a/src/plugins/data/public/search/search_interceptor.test.ts +++ b/src/plugins/data/public/search/search_interceptor.test.ts @@ -95,21 +95,23 @@ describe('SearchInterceptor', () => { }); describe('Search session', () => { - const setup = ({ - isRestore = false, - isStored = false, - sessionId, - }: { - isRestore?: boolean; - isStored?: boolean; - sessionId: string; - }) => { + const setup = ( + opts: { + isRestore?: boolean; + isStored?: boolean; + sessionId: string; + } | null + ) => { const sessionServiceMock = searchMock.session as jest.Mocked; - sessionServiceMock.getSearchOptions.mockImplementation(() => ({ - sessionId, - isRestore, - isStored, - })); + sessionServiceMock.getSearchOptions.mockImplementation(() => + opts + ? { + sessionId: opts.sessionId, + isRestore: opts.isRestore ?? false, + isStored: opts.isStored ?? false, + } + : null + ); fetchMock.mockResolvedValue({ result: 200 }); }; @@ -142,6 +144,22 @@ describe('SearchInterceptor', () => { (searchMock.session as jest.Mocked).getSearchOptions ).toHaveBeenCalledWith(sessionId); }); + + test("doesn't forward sessionId if search options return null", async () => { + const sessionId = 'sid'; + setup(null); + + await searchInterceptor.search(mockRequest, { sessionId }).toPromise(); + expect(fetchMock.mock.calls[0][0]).toEqual( + expect.not.objectContaining({ + options: { sessionId }, + }) + ); + + expect( + (searchMock.session as jest.Mocked).getSearchOptions + ).toHaveBeenCalledWith(sessionId); + }); }); describe('Should throw typed errors', () => { diff --git a/src/plugins/data/public/search/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor.ts index fde8ac9f25d6ed..f33740cc45bf98 100644 --- a/src/plugins/data/public/search/search_interceptor.ts +++ b/src/plugins/data/public/search/search_interceptor.ts @@ -126,14 +126,14 @@ export class SearchInterceptor { request: IKibanaSearchRequest, options?: ISearchOptions ): Promise { - const { abortSignal, ...requestOptions } = options || {}; + const { abortSignal, sessionId, ...requestOptions } = options || {}; return this.batchedFetch( { request, options: { ...requestOptions, - ...(options?.sessionId && this.deps.session.getSearchOptions(options.sessionId)), + ...this.deps.session.getSearchOptions(sessionId), }, }, abortSignal diff --git a/src/plugins/data/public/search/session/constants.ts b/src/plugins/data/public/search/session/constants.ts new file mode 100644 index 00000000000000..5496a541bfd453 --- /dev/null +++ b/src/plugins/data/public/search/session/constants.ts @@ -0,0 +1,9 @@ +/* + * 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. + */ + +export const SEARCH_SESSIONS_MANAGEMENT_ID = 'search_sessions'; diff --git a/src/plugins/data/public/search/session/index.ts b/src/plugins/data/public/search/session/index.ts index 82ba1e703a6d68..15410400a33e64 100644 --- a/src/plugins/data/public/search/session/index.ts +++ b/src/plugins/data/public/search/session/index.ts @@ -10,3 +10,4 @@ export { SessionService, ISessionService, SearchSessionInfoProvider } from './se export { SearchSessionState } from './search_session_state'; export { SessionsClient, ISessionsClient } from './sessions_client'; export { noSearchSessionStorageCapabilityMessage } from './i18n'; +export { SEARCH_SESSIONS_MANAGEMENT_ID } from './constants'; diff --git a/src/plugins/data/public/search/session/mocks.ts b/src/plugins/data/public/search/session/mocks.ts index f6a70d157b5a06..c615be641078b4 100644 --- a/src/plugins/data/public/search/session/mocks.ts +++ b/src/plugins/data/public/search/session/mocks.ts @@ -41,5 +41,6 @@ export function getSessionServiceMock(): jest.Mocked { enableStorage: jest.fn(), isSessionStorageReady: jest.fn(() => true), getSearchSessionIndicatorUiConfig: jest.fn(() => ({ isDisabled: () => ({ disabled: false }) })), + hasAccess: jest.fn(() => true), }; } diff --git a/src/plugins/data/public/search/session/session_service.test.ts b/src/plugins/data/public/search/session/session_service.test.ts index 54c402f51ec70e..3d49c91fea44e3 100644 --- a/src/plugins/data/public/search/session/session_service.test.ts +++ b/src/plugins/data/public/search/session/session_service.test.ts @@ -14,11 +14,13 @@ import { BehaviorSubject } from 'rxjs'; import { SearchSessionState } from './search_session_state'; import { createNowProviderMock } from '../../now_provider/mocks'; import { NowProviderInternalContract } from '../../now_provider'; +import { SEARCH_SESSIONS_MANAGEMENT_ID } from './constants'; describe('Session service', () => { let sessionService: ISessionService; let state$: BehaviorSubject; let nowProvider: jest.Mocked; + let userHasAccessToSearchSessions = true; beforeEach(() => { const initializerContext = coreMock.createPluginInitializerContext(); @@ -30,7 +32,18 @@ describe('Session service', () => { startService().then(([coreStart, ...rest]) => [ { ...coreStart, - application: { ...coreStart.application, currentAppId$: new BehaviorSubject('app') }, + application: { + ...coreStart.application, + currentAppId$: new BehaviorSubject('app'), + capabilities: { + ...coreStart.application.capabilities, + management: { + kibana: { + [SEARCH_SESSIONS_MANAGEMENT_ID]: userHasAccessToSearchSessions, + }, + }, + }, + }, }, ...rest, ]), @@ -146,6 +159,8 @@ describe('Session service', () => { isRestore: true, sessionId, }); + + expect(sessionService.getSearchOptions(undefined)).toBeNull(); }); test('isCurrentSession', () => { expect(sessionService.isCurrentSession()).toBeFalsy(); @@ -214,4 +229,25 @@ describe('Session service', () => { sessionService.start(); await expect(() => sessionService.save()).rejects.toMatchInlineSnapshot(`[Error: Haha]`); }); + + describe("user doesn't have access to search session", () => { + beforeAll(() => { + userHasAccessToSearchSessions = false; + }); + afterAll(() => { + userHasAccessToSearchSessions = true; + }); + + test("getSearchOptions doesn't return sessionId", () => { + const sessionId = sessionService.start(); + expect(sessionService.getSearchOptions(sessionId)).toBeNull(); + }); + + test('save() throws', async () => { + sessionService.start(); + await expect(() => sessionService.save()).rejects.toThrowErrorMatchingInlineSnapshot( + `"No access to search sessions"` + ); + }); + }); }); diff --git a/src/plugins/data/public/search/session/session_service.ts b/src/plugins/data/public/search/session/session_service.ts index 79ae64c5846a50..4286edf27cd40e 100644 --- a/src/plugins/data/public/search/session/session_service.ts +++ b/src/plugins/data/public/search/session/session_service.ts @@ -20,6 +20,7 @@ import { import { ISessionsClient } from './sessions_client'; import { ISearchOptions } from '../../../common'; import { NowProviderInternalContract } from '../../now_provider'; +import { SEARCH_SESSIONS_MANAGEMENT_ID } from './constants'; export type ISessionService = PublicContract; @@ -68,6 +69,7 @@ export class SessionService { private searchSessionIndicatorUiConfig?: Partial; private subscription = new Subscription(); private curApp?: string; + private hasAccessToSearchSessions: boolean = false; constructor( initializerContext: PluginInitializerContext, @@ -94,6 +96,10 @@ export class SessionService { ); getStartServices().then(([coreStart]) => { + // using management?.kibana? we infer if any of the apps allows current user to store sessions + this.hasAccessToSearchSessions = + coreStart.application.capabilities.management?.kibana?.[SEARCH_SESSIONS_MANAGEMENT_ID]; + // Apps required to clean up their sessions before unmounting // Make sure that apps don't leave sessions open. this.subscription.add( @@ -117,6 +123,15 @@ export class SessionService { }); } + /** + * If user has access to search sessions + * This resolves to `true` in case at least one app allows user to create search session + * In this case search session management is available + */ + public hasAccess() { + return this.hasAccessToSearchSessions; + } + /** * Used to track pending searches within current session * @@ -215,6 +230,7 @@ export class SessionService { const sessionId = this.getSessionId(); if (!sessionId) throw new Error('No current session'); if (!this.curApp) throw new Error('No current app id'); + if (!this.hasAccess()) throw new Error('No access to search sessions'); const currentSessionInfoProvider = this.searchSessionInfoProvider; if (!currentSessionInfoProvider) throw new Error('No info provider for current session'); const [name, { initialState, restoreState, urlGeneratorId }] = await Promise.all([ @@ -247,11 +263,25 @@ export class SessionService { /** * Infers search session options for sessionId using current session state + * + * In case user doesn't has access to `search-session` SO returns null, + * meaning that sessionId and other session parameters shouldn't be used when doing searches + * * @param sessionId */ public getSearchOptions( - sessionId: string - ): Required> { + sessionId?: string + ): Required> | null { + if (!sessionId) { + return null; + } + + // in case user doesn't have permissions to search session, do not forward sessionId to the server + // because user most likely also doesn't have access to `search-session` SO + if (!this.hasAccessToSearchSessions) { + return null; + } + const isCurrentSession = this.isCurrentSession(sessionId); return { sessionId, diff --git a/src/plugins/vis_type_timelion/public/helpers/timelion_request_handler.ts b/src/plugins/vis_type_timelion/public/helpers/timelion_request_handler.ts index c07fd0a2781976..7e8f28bd32b2fd 100644 --- a/src/plugins/vis_type_timelion/public/helpers/timelion_request_handler.ts +++ b/src/plugins/vis_type_timelion/public/helpers/timelion_request_handler.ts @@ -94,6 +94,7 @@ export function getTimelionRequestHandler({ }); try { + const searchSessionOptions = dataSearch.session.getSearchOptions(searchSessionId); return await http.post('/api/timelion/run', { body: JSON.stringify({ sheet: [expression], @@ -108,8 +109,8 @@ export function getTimelionRequestHandler({ interval: visParams.interval, timezone, }, - ...(searchSessionId && { - searchSession: dataSearch.session.getSearchOptions(searchSessionId), + ...(searchSessionOptions && { + searchSession: searchSessionOptions, }), }), }); diff --git a/src/plugins/vis_type_timeseries/public/request_handler.ts b/src/plugins/vis_type_timeseries/public/request_handler.ts index c7beccbceca1a9..d0526f7e1d886e 100644 --- a/src/plugins/vis_type_timeseries/public/request_handler.ts +++ b/src/plugins/vis_type_timeseries/public/request_handler.ts @@ -48,6 +48,7 @@ export const metricsRequestHandler = async ({ }); try { + const searchSessionOptions = dataSearch.session.getSearchOptions(searchSessionId); return await getCoreStart().http.post(ROUTES.VIS_DATA, { body: JSON.stringify({ timerange: { @@ -58,8 +59,8 @@ export const metricsRequestHandler = async ({ filters: input?.filters, panels: [visParams], state: uiStateObj, - ...(searchSessionId && { - searchSession: dataSearch.session.getSearchOptions(searchSessionId), + ...(searchSessionOptions && { + searchSession: searchSessionOptions, }), }), }); diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/index.ts b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/index.ts index 332b30809077c7..e13cd06f52a4d7 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/index.ts +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/index.ts @@ -15,6 +15,7 @@ import type { ConfigSchema } from '../../../config'; import type { DataEnhancedStartDependencies } from '../../plugin'; import type { SearchSessionsMgmtAPI } from './lib/api'; import type { AsyncSearchIntroDocumentation } from './lib/documentation'; +import { SEARCH_SESSIONS_MANAGEMENT_ID } from '../../../../../../src/plugins/data/public'; export interface IManagementSectionsPluginsSetup { management: ManagementSetup; @@ -38,7 +39,7 @@ export interface AppDependencies { } export const APP = { - id: 'search_sessions', + id: SEARCH_SESSIONS_MANAGEMENT_ID, getI18nName: (): string => i18n.translate('xpack.data.mgmt.searchSessions.appTitle', { defaultMessage: 'Search Sessions', diff --git a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx index 3437920ed7c98a..aacb86f269727a 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx @@ -24,6 +24,7 @@ import userEvent from '@testing-library/user-event'; import { IntlProvider } from 'react-intl'; const coreStart = coreMock.createStart(); +const application = coreStart.application; const dataStart = dataPluginMock.createStartContract(); const sessionService = dataStart.search.session as jest.Mocked; let storage: Storage; @@ -52,7 +53,7 @@ beforeEach(() => { test("shouldn't show indicator in case no active search session", async () => { const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService, - application: coreStart.application, + application, timeFilter, storage, disableSaveAfterSessionCompletesTimeout, @@ -79,7 +80,7 @@ test("shouldn't show indicator in case no active search session", async () => { test("shouldn't show indicator in case app hasn't opt-in", async () => { const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService, - application: coreStart.application, + application, timeFilter, storage, disableSaveAfterSessionCompletesTimeout, @@ -108,7 +109,7 @@ test('should show indicator in case there is an active search session', async () const state$ = new BehaviorSubject(SearchSessionState.Loading); const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService: { ...sessionService, state$ }, - application: coreStart.application, + application, timeFilter, storage, disableSaveAfterSessionCompletesTimeout, @@ -124,12 +125,6 @@ test('should show indicator in case there is an active search session', async () test('should be disabled in case uiConfig says so ', async () => { const state$ = new BehaviorSubject(SearchSessionState.Loading); - coreStart.application.currentAppId$ = new BehaviorSubject('discover'); - (coreStart.application.capabilities as any) = { - discover: { - storeSearchSession: false, - }, - }; sessionService.getSearchSessionIndicatorUiConfig.mockImplementation(() => ({ isDisabled: () => ({ disabled: true, @@ -138,7 +133,7 @@ test('should be disabled in case uiConfig says so ', async () => { })); const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService: { ...sessionService, state$ }, - application: coreStart.application, + application, timeFilter, storage, disableSaveAfterSessionCompletesTimeout, @@ -157,12 +152,36 @@ test('should be disabled in case uiConfig says so ', async () => { expect(screen.getByRole('button', { name: 'Save session' })).toBeDisabled(); }); +test('should be disabled in case not enough permissions', async () => { + const state$ = new BehaviorSubject(SearchSessionState.Completed); + const SearchSessionIndicator = createConnectedSearchSessionIndicator({ + sessionService: { ...sessionService, state$, hasAccess: () => false }, + application, + timeFilter, + storage, + disableSaveAfterSessionCompletesTimeout, + }); + + render( + + + + ); + + await waitFor(() => screen.getByTestId('searchSessionIndicator')); + + await userEvent.click(screen.getByLabelText('Search session complete')); + + expect(screen.getByRole('button', { name: 'Save session' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Manage sessions' })).toBeDisabled(); +}); + test('should be disabled during auto-refresh', async () => { const state$ = new BehaviorSubject(SearchSessionState.Loading); const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService: { ...sessionService, state$ }, - application: coreStart.application, + application, timeFilter, storage, disableSaveAfterSessionCompletesTimeout, @@ -199,7 +218,7 @@ describe('Completed inactivity', () => { const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService: { ...sessionService, state$ }, - application: coreStart.application, + application, timeFilter, storage, disableSaveAfterSessionCompletesTimeout, @@ -257,7 +276,7 @@ describe('tour steps', () => { const state$ = new BehaviorSubject(SearchSessionState.Loading); const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService: { ...sessionService, state$ }, - application: coreStart.application, + application, timeFilter, storage, disableSaveAfterSessionCompletesTimeout, @@ -294,7 +313,7 @@ describe('tour steps', () => { const state$ = new BehaviorSubject(SearchSessionState.Loading); const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService: { ...sessionService, state$ }, - application: coreStart.application, + application, timeFilter, storage, disableSaveAfterSessionCompletesTimeout, @@ -325,7 +344,7 @@ describe('tour steps', () => { const state$ = new BehaviorSubject(SearchSessionState.Restored); const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService: { ...sessionService, state$ }, - application: coreStart.application, + application, timeFilter, storage, disableSaveAfterSessionCompletesTimeout, @@ -347,7 +366,7 @@ describe('tour steps', () => { const state$ = new BehaviorSubject(SearchSessionState.Completed); const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService: { ...sessionService, state$ }, - application: coreStart.application, + application, timeFilter, storage, disableSaveAfterSessionCompletesTimeout, diff --git a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx index 3935b5bb2814b7..81769e5a25544f 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx @@ -79,6 +79,9 @@ export const createConnectedSearchSessionIndicator = ({ let saveDisabled = false; let saveDisabledReasonText: string = ''; + let managementDisabled = false; + let managementDisabledReasonText: string = ''; + if (autoRefreshEnabled) { saveDisabled = true; saveDisabledReasonText = i18n.translate( @@ -104,6 +107,18 @@ export const createConnectedSearchSessionIndicator = ({ saveDisabledReasonText = isSaveDisabledByApp.reasonText; } + // check if user doesn't have access to search_sessions and search_sessions mgtm + // this happens in case there is no app that allows current user to use search session + if (!sessionService.hasAccess()) { + managementDisabled = saveDisabled = true; + managementDisabledReasonText = saveDisabledReasonText = i18n.translate( + 'xpack.data.searchSessionIndicator.disabledDueToDisabledGloballyMessage', + { + defaultMessage: "You don't have permissions to manage search sessions", + } + ); + } + const { markOpenedDone, markRestoredDone } = useSearchSessionTour( storage, searchSessionIndicator, @@ -143,6 +158,8 @@ export const createConnectedSearchSessionIndicator = ({ state={state} saveDisabled={saveDisabled} saveDisabledReasonText={saveDisabledReasonText} + managementDisabled={managementDisabled} + managementDisabledReasonText={managementDisabledReasonText} onContinueInBackground={onContinueInBackground} onSaveResults={onSaveResults} onCancel={onCancel} diff --git a/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.tsx b/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.tsx index eb58039ff58f7d..0d31ce0c98f194 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.tsx @@ -31,7 +31,8 @@ export interface SearchSessionIndicatorProps { onCancel?: () => void; viewSearchSessionsLink?: string; onSaveResults?: () => void; - + managementDisabled?: boolean; + managementDisabledReasonText?: string; saveDisabled?: boolean; saveDisabledReasonText?: string; @@ -78,17 +79,22 @@ const ContinueInBackgroundButton = ({ const ViewAllSearchSessionsButton = ({ viewSearchSessionsLink = 'management/kibana/search_sessions', buttonProps = {}, + managementDisabled, + managementDisabledReasonText, }: ActionButtonProps) => ( - - - + + + + + ); const SaveButton = ({ 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 8432fdac93a9a7..c941badcad2233 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 @@ -67,7 +67,11 @@ Array [ "catalogue": Array [ "dashboard", ], - "management": Object {}, + "management": Object { + "kibana": Array [ + "search_sessions", + ], + }, "savedObject": Object { "all": Array [ "dashboard", @@ -200,7 +204,11 @@ Array [ "catalogue": Array [ "discover", ], - "management": Object {}, + "management": Object { + "kibana": Array [ + "search_sessions", + ], + }, "savedObject": Object { "all": Array [ "search", @@ -553,7 +561,11 @@ Array [ "catalogue": Array [ "dashboard", ], - "management": Object {}, + "management": Object { + "kibana": Array [ + "search_sessions", + ], + }, "savedObject": Object { "all": Array [ "dashboard", @@ -686,7 +698,11 @@ Array [ "catalogue": Array [ "discover", ], - "management": Object {}, + "management": Object { + "kibana": Array [ + "search_sessions", + ], + }, "savedObject": Object { "all": Array [ "search", diff --git a/x-pack/plugins/features/server/oss_features.ts b/x-pack/plugins/features/server/oss_features.ts index 2d9e01427a277c..6c599461f438aa 100644 --- a/x-pack/plugins/features/server/oss_features.ts +++ b/x-pack/plugins/features/server/oss_features.ts @@ -21,6 +21,9 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS name: i18n.translate('xpack.features.discoverFeatureName', { defaultMessage: 'Discover', }), + management: { + kibana: ['search_sessions'], + }, order: 100, category: DEFAULT_APP_CATEGORIES.kibana, app: ['discover', 'kibana'], @@ -95,6 +98,9 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS read: [], }, ui: ['storeSearchSession'], + management: { + kibana: ['search_sessions'], + }, }, ], }, @@ -166,6 +172,9 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS name: i18n.translate('xpack.features.dashboardFeatureName', { defaultMessage: 'Dashboard', }), + management: { + kibana: ['search_sessions'], + }, order: 200, category: DEFAULT_APP_CATEGORIES.kibana, app: ['dashboards', 'kibana'], @@ -260,6 +269,9 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS read: [], }, ui: ['storeSearchSession'], + management: { + kibana: ['search_sessions'], + }, }, ], }, diff --git a/x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/index.ts b/x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/index.ts index 994d91ae4a27b9..0798a25a2e9820 100644 --- a/x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/index.ts +++ b/x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/index.ts @@ -22,5 +22,6 @@ export default function ({ loadTestFile, getService }: FtrProviderContext) { }); loadTestFile(require.resolve('./sessions_management')); + loadTestFile(require.resolve('./sessions_management_permissions')); }); } diff --git a/x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/sessions_management_permissions.ts b/x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/sessions_management_permissions.ts new file mode 100644 index 00000000000000..48f4156afbe82b --- /dev/null +++ b/x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/sessions_management_permissions.ts @@ -0,0 +1,111 @@ +/* + * 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 default function ({ getService, getPageObjects }: FtrProviderContext) { + const security = getService('security'); + const PageObjects = getPageObjects([ + 'common', + 'header', + 'dashboard', + 'visChart', + 'searchSessionsManagement', + 'security', + ]); + + const appsMenu = getService('appsMenu'); + const managementMenu = getService('managementMenu'); + + describe('Search sessions Management UI permissions', () => { + describe('Sessions management is not available if non of apps enable search sessions', () => { + before(async () => { + await security.role.create('data_analyst', { + elasticsearch: {}, + kibana: [ + { + feature: { + dashboard: ['read'], + }, + spaces: ['*'], + }, + ], + }); + + await security.user.create('analyst', { + password: 'analyst-password', + roles: ['data_analyst'], + full_name: 'test user', + }); + + await PageObjects.security.forceLogout(); + + await PageObjects.security.login('analyst', 'analyst-password', { + expectSpaceSelector: false, + }); + }); + + after(async () => { + await security.role.delete('data_analyst'); + await security.user.delete('analyst'); + await PageObjects.security.forceLogout(); + }); + + it('Sessions management is not available if non of apps enable search sessions', async () => { + const links = await appsMenu.readLinks(); + expect(links.map((link) => link.text)).to.not.contain('Stack Management'); + }); + }); + + describe('Sessions management is available if one of apps enables search sessions', () => { + before(async () => { + await security.role.create('data_analyst', { + elasticsearch: {}, + kibana: [ + { + feature: { + dashboard: ['read', 'store_search_session'], + }, + spaces: ['*'], + }, + ], + }); + + await security.user.create('analyst', { + password: 'analyst-password', + roles: ['data_analyst'], + full_name: 'test user', + }); + + await PageObjects.security.forceLogout(); + + await PageObjects.security.login('analyst', 'analyst-password', { + expectSpaceSelector: false, + }); + }); + + after(async () => { + await security.role.delete('data_analyst'); + await security.user.delete('analyst'); + await PageObjects.security.forceLogout(); + }); + + it('Sessions management is available if one of apps enables search sessions', async () => { + const links = await appsMenu.readLinks(); + expect(links.map((link) => link.text)).to.contain('Stack Management'); + await PageObjects.common.navigateToApp('management'); + const sections = await managementMenu.getSections(); + expect(sections).to.have.length(1); + expect(sections[0]).to.eql({ + sectionId: 'kibana', + sectionLinks: ['search_sessions'], + }); + }); + }); + }); +}