[Search Session] Control "Kibana / Search Sessions" management section by privileges (#90818)

This commit is contained in:
Anton Dosov 2021-02-11 10:33:32 +01:00 committed by GitHub
parent c91e5fe3f2
commit 9ab5bcb141
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 370 additions and 71 deletions

View file

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

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) &gt; [SEARCH\_SESSIONS\_MANAGEMENT\_ID](./kibana-plugin-plugins-data-public.search_sessions_management_id.md)
## SEARCH\_SESSIONS\_MANAGEMENT\_ID variable
<b>Signature:</b>
```typescript
SEARCH_SESSIONS_MANAGEMENT_ID = "search_sessions"
```

View file

@ -381,6 +381,7 @@ export {
TimeoutErrorMode,
PainlessError,
noSearchSessionStorageCapabilityMessage,
SEARCH_SESSIONS_MANAGEMENT_ID,
} from './search';
export type {

View file

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

View file

@ -38,6 +38,7 @@ export {
SessionsClient,
ISessionsClient,
noSearchSessionStorageCapabilityMessage,
SEARCH_SESSIONS_MANAGEMENT_ID,
} from './session';
export { getEsPreference } from './es_search';

View file

@ -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<ISessionService>;
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<ISessionService>).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<ISessionService>).getSearchOptions
).toHaveBeenCalledWith(sessionId);
});
});
describe('Should throw typed errors', () => {

View file

@ -126,14 +126,14 @@ export class SearchInterceptor {
request: IKibanaSearchRequest,
options?: ISearchOptions
): Promise<IKibanaSearchResponse> {
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

View file

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

View file

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

View file

@ -41,5 +41,6 @@ export function getSessionServiceMock(): jest.Mocked<ISessionService> {
enableStorage: jest.fn(),
isSessionStorageReady: jest.fn(() => true),
getSearchSessionIndicatorUiConfig: jest.fn(() => ({ isDisabled: () => ({ disabled: false }) })),
hasAccess: jest.fn(() => true),
};
}

View file

@ -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<SearchSessionState>;
let nowProvider: jest.Mocked<NowProviderInternalContract>;
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"`
);
});
});
});

View file

@ -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<SessionService>;
@ -68,6 +69,7 @@ export class SessionService {
private searchSessionIndicatorUiConfig?: Partial<SearchSessionIndicatorUiConfig>;
private subscription = new Subscription();
private curApp?: string;
private hasAccessToSearchSessions: boolean = false;
constructor(
initializerContext: PluginInitializerContext<ConfigSchema>,
@ -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<Pick<ISearchOptions, 'sessionId' | 'isRestore' | 'isStored'>> {
sessionId?: string
): Required<Pick<ISearchOptions, 'sessionId' | 'isRestore' | 'isStored'>> | 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,

View file

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

View file

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

View file

@ -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',

View file

@ -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<ISessionService>;
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(
<Container>
<SearchSessionIndicator />
</Container>
);
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,

View file

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

View file

@ -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) => (
<EuiButtonEmpty
href={viewSearchSessionsLink}
data-test-subj={'searchSessionIndicatorViewSearchSessionsLink'}
{...buttonProps}
>
<FormattedMessage
id="xpack.data.searchSessionIndicator.viewSearchSessionsLinkText"
defaultMessage="Manage sessions"
/>
</EuiButtonEmpty>
<EuiToolTip content={managementDisabledReasonText}>
<EuiButtonEmpty
href={viewSearchSessionsLink}
data-test-subj={'searchSessionIndicatorViewSearchSessionsLink'}
isDisabled={managementDisabled}
{...buttonProps}
>
<FormattedMessage
id="xpack.data.searchSessionIndicator.viewSearchSessionsLinkText"
defaultMessage="Manage sessions"
/>
</EuiButtonEmpty>
</EuiToolTip>
);
const SaveButton = ({

View file

@ -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",

View file

@ -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'],
},
},
],
},

View file

@ -22,5 +22,6 @@ export default function ({ loadTestFile, getService }: FtrProviderContext) {
});
loadTestFile(require.resolve('./sessions_management'));
loadTestFile(require.resolve('./sessions_management_permissions'));
});
}

View file

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