From 7727ab74d2979c9c4c13a56d5809ca288020f2a9 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Thu, 21 Jan 2021 18:45:13 +0100 Subject: [PATCH 01/16] [Docs] Clean up state management examples (#88980) --- examples/state_containers_examples/README.md | 2 +- .../state_containers_examples/common/index.ts | 10 - .../state_containers_examples/kibana.json | 4 +- .../public/common/example_page.tsx | 62 +++++ .../public/plugin.ts | 106 ++++---- .../public/state_sync.png | Bin 0 -> 14406 bytes .../public/todo/app.tsx | 33 +-- .../public/todo/todo.tsx | 255 +++++++----------- .../{components => }/app.tsx | 148 +++++----- .../public/with_data_services/application.tsx | 14 +- .../state_containers_examples/server/index.ts | 17 -- .../server/plugin.ts | 45 ---- .../server/routes/index.ts | 25 -- .../state_containers_examples/server/types.ts | 12 - src/plugins/kibana_react/kibana.json | 3 +- src/plugins/kibana_react/public/index.ts | 1 - .../public/use_url_tracker/index.ts | 9 - .../use_url_tracker/use_url_tracker.test.tsx | 59 ---- .../use_url_tracker/use_url_tracker.tsx | 41 --- 19 files changed, 302 insertions(+), 544 deletions(-) delete mode 100644 examples/state_containers_examples/common/index.ts create mode 100644 examples/state_containers_examples/public/common/example_page.tsx create mode 100644 examples/state_containers_examples/public/state_sync.png rename examples/state_containers_examples/public/with_data_services/{components => }/app.tsx (58%) delete mode 100644 examples/state_containers_examples/server/index.ts delete mode 100644 examples/state_containers_examples/server/plugin.ts delete mode 100644 examples/state_containers_examples/server/routes/index.ts delete mode 100644 examples/state_containers_examples/server/types.ts delete mode 100644 src/plugins/kibana_react/public/use_url_tracker/index.ts delete mode 100644 src/plugins/kibana_react/public/use_url_tracker/use_url_tracker.test.tsx delete mode 100644 src/plugins/kibana_react/public/use_url_tracker/use_url_tracker.tsx diff --git a/examples/state_containers_examples/README.md b/examples/state_containers_examples/README.md index c4c6642789bd9d..015959a2f78199 100644 --- a/examples/state_containers_examples/README.md +++ b/examples/state_containers_examples/README.md @@ -2,7 +2,7 @@ This example app shows how to: - Use state containers to manage your application state - - Integrate with browser history and hash history routing + - Integrate with browser history or hash history routing - Sync your state container with the URL To run this example, use the command `yarn start --run-examples`. diff --git a/examples/state_containers_examples/common/index.ts b/examples/state_containers_examples/common/index.ts deleted file mode 100644 index 0d0bc48fca450b..00000000000000 --- a/examples/state_containers_examples/common/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * and the Server Side Public License, v 1; you may not use this file except in - * compliance with, at your election, the Elastic License or the Server Side - * Public License, v 1. - */ - -export const PLUGIN_ID = 'stateContainersExampleWithDataServices'; -export const PLUGIN_NAME = 'State containers example - with data services'; diff --git a/examples/state_containers_examples/kibana.json b/examples/state_containers_examples/kibana.json index 58346af8f1d191..0f0a3a805ecb5d 100644 --- a/examples/state_containers_examples/kibana.json +++ b/examples/state_containers_examples/kibana.json @@ -2,9 +2,9 @@ "id": "stateContainersExamples", "version": "0.0.1", "kibanaVersion": "kibana", - "server": true, + "server": false, "ui": true, "requiredPlugins": ["navigation", "data", "developerExamples"], "optionalPlugins": [], - "requiredBundles": ["kibanaUtils", "kibanaReact"] + "requiredBundles": ["kibanaUtils"] } diff --git a/examples/state_containers_examples/public/common/example_page.tsx b/examples/state_containers_examples/public/common/example_page.tsx new file mode 100644 index 00000000000000..203b226158d0e1 --- /dev/null +++ b/examples/state_containers_examples/public/common/example_page.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import React, { PropsWithChildren } from 'react'; +import { EuiPage, EuiPageSideBar, EuiSideNav } from '@elastic/eui'; +import { CoreStart } from '../../../../src/core/public'; + +export interface ExampleLink { + title: string; + appId: string; +} + +interface NavProps { + navigateToApp: CoreStart['application']['navigateToApp']; + exampleLinks: ExampleLink[]; +} + +const SideNav: React.FC = ({ navigateToApp, exampleLinks }: NavProps) => { + const navItems = exampleLinks.map((example) => ({ + id: example.appId, + name: example.title, + onClick: () => navigateToApp(example.appId), + 'data-test-subj': example.appId, + })); + + return ( + + ); +}; + +interface Props { + navigateToApp: CoreStart['application']['navigateToApp']; + exampleLinks: ExampleLink[]; +} + +export const StateContainersExamplesPage: React.FC = ({ + navigateToApp, + children, + exampleLinks, +}: PropsWithChildren) => { + return ( + + + + + {children} + + ); +}; diff --git a/examples/state_containers_examples/public/plugin.ts b/examples/state_containers_examples/public/plugin.ts index 752c0935c5dd03..a775c3d65fd7a2 100644 --- a/examples/state_containers_examples/public/plugin.ts +++ b/examples/state_containers_examples/public/plugin.ts @@ -8,8 +8,8 @@ import { AppMountParameters, CoreSetup, Plugin, AppNavLinkStatus } from '../../../src/core/public'; import { AppPluginDependencies } from './with_data_services/types'; -import { PLUGIN_ID, PLUGIN_NAME } from '../common'; import { DeveloperExamplesSetup } from '../../developer_examples/public'; +import image from './state_sync.png'; interface SetupDeps { developerExamples: DeveloperExamplesSetup; @@ -17,97 +17,95 @@ interface SetupDeps { export class StateContainersExamplesPlugin implements Plugin { public setup(core: CoreSetup, { developerExamples }: SetupDeps) { + const examples = { + stateContainersExampleBrowserHistory: { + title: 'Todo App (browser history)', + }, + stateContainersExampleHashHistory: { + title: 'Todo App (hash history)', + }, + stateContainersExampleWithDataServices: { + title: 'Search bar integration', + }, + }; + + const exampleLinks = Object.keys(examples).map((id: string) => ({ + appId: id, + title: examples[id as keyof typeof examples].title, + })); + core.application.register({ id: 'stateContainersExampleBrowserHistory', - title: 'State containers example - browser history routing', + title: examples.stateContainersExampleBrowserHistory.title, navLinkStatus: AppNavLinkStatus.hidden, async mount(params: AppMountParameters) { const { renderApp, History } = await import('./todo/app'); - return renderApp(params, { - appInstanceId: '1', - appTitle: 'Routing with browser history', - historyType: History.Browser, - }); + const [coreStart] = await core.getStartServices(); + return renderApp( + params, + { + appTitle: examples.stateContainersExampleBrowserHistory.title, + historyType: History.Browser, + }, + { navigateToApp: coreStart.application.navigateToApp, exampleLinks } + ); }, }); core.application.register({ id: 'stateContainersExampleHashHistory', - title: 'State containers example - hash history routing', + title: examples.stateContainersExampleHashHistory.title, navLinkStatus: AppNavLinkStatus.hidden, async mount(params: AppMountParameters) { const { renderApp, History } = await import('./todo/app'); - return renderApp(params, { - appInstanceId: '2', - appTitle: 'Routing with hash history', - historyType: History.Hash, - }); + const [coreStart] = await core.getStartServices(); + return renderApp( + params, + { + appTitle: examples.stateContainersExampleHashHistory.title, + historyType: History.Hash, + }, + { navigateToApp: coreStart.application.navigateToApp, exampleLinks } + ); }, }); core.application.register({ - id: PLUGIN_ID, - title: PLUGIN_NAME, + id: 'stateContainersExampleWithDataServices', + title: examples.stateContainersExampleWithDataServices.title, navLinkStatus: AppNavLinkStatus.hidden, async mount(params: AppMountParameters) { - // Load application bundle const { renderApp } = await import('./with_data_services/application'); - // Get start services as specified in kibana.json const [coreStart, depsStart] = await core.getStartServices(); - // Render the application - return renderApp(coreStart, depsStart as AppPluginDependencies, params); + return renderApp(coreStart, depsStart as AppPluginDependencies, params, { exampleLinks }); }, }); developerExamples.register({ - appId: 'stateContainersExampleBrowserHistory', - title: 'State containers using browser history', - description: `An example todo app that uses browser history and state container utilities like createStateContainerReactHelpers, - createStateContainer, createKbnUrlStateStorage, createSessionStorageStateStorage, - syncStates and getStateFromKbnUrl to keep state in sync with the URL. Change some parameters, navigate away and then back, and the - state should be preserved.`, + appId: exampleLinks[0].appId, + title: 'State Management', + description: 'Examples of using state containers and state syncing utils.', + image, links: [ { - label: 'README', + label: 'State containers README', href: - 'https://github.com/elastic/kibana/tree/master/src/plugins/kibana_utils/docs/state_containers/README.md', + 'https://github.com/elastic/kibana/tree/master/src/plugins/kibana_utils/docs/state_containers', iconType: 'logoGithub', size: 's', target: '_blank', }, - ], - }); - - developerExamples.register({ - appId: 'stateContainersExampleHashHistory', - title: 'State containers using hash history', - description: `An example todo app that uses hash history and state container utilities like createStateContainerReactHelpers, - createStateContainer, createKbnUrlStateStorage, createSessionStorageStateStorage, - syncStates and getStateFromKbnUrl to keep state in sync with the URL. Change some parameters, navigate away and then back, and the - state should be preserved.`, - links: [ { - label: 'README', + label: 'State sync utils README', href: - 'https://github.com/elastic/kibana/tree/master/src/plugins/kibana_utils/docs/state_containers/README.md', + 'https://github.com/elastic/kibana/tree/master/src/plugins/kibana_utils/docs/state_sync', iconType: 'logoGithub', size: 's', target: '_blank', }, - ], - }); - - developerExamples.register({ - appId: PLUGIN_ID, - title: 'Sync state from a query bar with the url', - description: `Shows how to use data.syncQueryStateWitUrl in combination with state container utilities from kibana_utils to - show a query bar that stores state in the url and is kept in sync. - `, - links: [ { - label: 'README', - href: - 'https://github.com/elastic/kibana/blob/master/src/plugins/data/public/query/state_sync/README.md', - iconType: 'logoGithub', + label: 'Kibana navigation best practices', + href: 'https://www.elastic.co/guide/en/kibana/master/kibana-navigation.html', + iconType: 'logoKibana', size: 's', target: '_blank', }, diff --git a/examples/state_containers_examples/public/state_sync.png b/examples/state_containers_examples/public/state_sync.png new file mode 100644 index 0000000000000000000000000000000000000000..fc8eb0dc10f6a27a0ac02a87edef3d7ec9f706a6 GIT binary patch literal 14406 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 JBViK!e*n1p$>0D0 literal 0 HcmV?d00001 diff --git a/examples/state_containers_examples/public/todo/app.tsx b/examples/state_containers_examples/public/todo/app.tsx index ff4d65009a3671..f43ace6acee229 100644 --- a/examples/state_containers_examples/public/todo/app.tsx +++ b/examples/state_containers_examples/public/todo/app.tsx @@ -6,14 +6,14 @@ * Public License, v 1. */ -import { AppMountParameters } from 'kibana/public'; +import { AppMountParameters, CoreStart } from 'kibana/public'; import ReactDOM from 'react-dom'; import React from 'react'; import { createHashHistory } from 'history'; import { TodoAppPage } from './todo'; +import { StateContainersExamplesPage, ExampleLink } from '../common/example_page'; export interface AppOptions { - appInstanceId: string; appTitle: string; historyType: History; } @@ -23,30 +23,21 @@ export enum History { Hash, } +export interface Deps { + navigateToApp: CoreStart['application']['navigateToApp']; + exampleLinks: ExampleLink[]; +} + export const renderApp = ( { appBasePath, element, history: platformHistory }: AppMountParameters, - { appInstanceId, appTitle, historyType }: AppOptions + { appTitle, historyType }: AppOptions, + { navigateToApp, exampleLinks }: Deps ) => { const history = historyType === History.Browser ? platformHistory : createHashHistory(); ReactDOM.render( - { - const stripTrailingSlash = (path: string) => - path.charAt(path.length - 1) === '/' ? path.substr(0, path.length - 1) : path; - const currentAppUrl = stripTrailingSlash(history.createHref(history.location)); - if (historyType === History.Browser) { - // browser history - return currentAppUrl === '' && !history.location.search && !history.location.hash; - } else { - // hashed history - return currentAppUrl === '#' && !history.location.search; - } - }} - />, + + + , element ); diff --git a/examples/state_containers_examples/public/todo/todo.tsx b/examples/state_containers_examples/public/todo/todo.tsx index ba0b7d213f9fd2..efe45f15c809bc 100644 --- a/examples/state_containers_examples/public/todo/todo.tsx +++ b/examples/state_containers_examples/public/todo/todo.tsx @@ -6,7 +6,7 @@ * Public License, v 1. */ -import React, { useEffect } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { Link, Route, Router, Switch, useLocation } from 'react-router-dom'; import { History } from 'history'; import { @@ -18,21 +18,21 @@ import { EuiPageContentBody, EuiPageHeader, EuiPageHeaderSection, + EuiSpacer, + EuiText, EuiTitle, } from '@elastic/eui'; import { + BaseState, BaseStateContainer, - INullableBaseStateContainer, createKbnUrlStateStorage, - createSessionStorageStateStorage, createStateContainer, - createStateContainerReactHelpers, - PureTransition, - syncStates, getStateFromKbnUrl, - BaseState, + INullableBaseStateContainer, + StateContainer, + syncState, + useContainerSelector, } from '../../../../src/plugins/kibana_utils/public'; -import { useUrlTracker } from '../../../../src/plugins/kibana_react/public'; import { defaultState, pureTransitions, @@ -40,42 +40,24 @@ import { TodoState, } from '../../../../src/plugins/kibana_utils/demos/state_containers/todomvc'; -interface GlobalState { - text: string; -} -interface GlobalStateAction { - setText: PureTransition; -} -const defaultGlobalState: GlobalState = { text: '' }; -const globalStateContainer = createStateContainer( - defaultGlobalState, - { - setText: (state) => (text) => ({ ...state, text }), - } -); - -const GlobalStateHelpers = createStateContainerReactHelpers(); - -const container = createStateContainer(defaultState, pureTransitions); -const { Provider, connect, useTransitions, useState } = createStateContainerReactHelpers< - typeof container ->(); - interface TodoAppProps { filter: 'completed' | 'not-completed' | null; + stateContainer: StateContainer; } -const TodoApp: React.FC = ({ filter }) => { - const { setText } = GlobalStateHelpers.useTransitions(); - const { text } = GlobalStateHelpers.useState(); - const { edit: editTodo, delete: deleteTodo, add: addTodo } = useTransitions(); - const todos = useState().todos; - const filteredTodos = todos.filter((todo) => { - if (!filter) return true; - if (filter === 'completed') return todo.completed; - if (filter === 'not-completed') return !todo.completed; - return true; - }); +const TodoApp: React.FC = ({ filter, stateContainer }) => { + const { edit: editTodo, delete: deleteTodo, add: addTodo } = stateContainer.transitions; + const todos = useContainerSelector(stateContainer, (state) => state.todos); + const filteredTodos = useMemo( + () => + todos.filter((todo) => { + if (!filter) return true; + if (filter === 'completed') return todo.completed; + if (filter === 'not-completed') return !todo.completed; + return true; + }), + [todos, filter] + ); const location = useLocation(); return ( <> @@ -144,158 +126,115 @@ const TodoApp: React.FC = ({ filter }) => { > -
- - setText(e.target.value)} /> -
); }; -const TodoAppConnected = GlobalStateHelpers.connect(() => ({}))( - connect(() => ({}))(TodoApp) -); - export const TodoAppPage: React.FC<{ history: History; - appInstanceId: string; appTitle: string; appBasePath: string; - isInitialRoute: () => boolean; }> = (props) => { const initialAppUrl = React.useRef(window.location.href); - const [useHashedUrl, setUseHashedUrl] = React.useState(false); + const stateContainer = React.useMemo( + () => createStateContainer(defaultState, pureTransitions), + [] + ); - /** - * Replicates what src/legacy/ui/public/chrome/api/nav.ts did - * Persists the url in sessionStorage and tries to restore it on "componentDidMount" - */ - useUrlTracker(`lastUrlTracker:${props.appInstanceId}`, props.history, (urlToRestore) => { - // shouldRestoreUrl: - // App decides if it should restore url or not - // In this specific case, restore only if navigated to initial route - if (props.isInitialRoute()) { - // navigated to the base path, so should restore the url - return true; - } else { - // navigated to specific route, so should not restore the url - return false; - } - }); + // Most of kibana apps persist state in the URL in two ways: + // * Rison encoded. + // * Hashed URL: In the URL only the hash from the state is stored. The state itself is stored in + // the sessionStorage. See `state:storeInSessionStorage` advanced option for more context. + // This example shows how to use both of them + const [useHashedUrl, setUseHashedUrl] = React.useState(false); useEffect(() => { - // have to sync with history passed to react-router - // history v5 will be singleton and this will not be needed + // storage to sync our app state with + // in this case we want to sync state with query params in the URL serialised in rison format + // similar like Discover or Dashboard apps do const kbnUrlStateStorage = createKbnUrlStateStorage({ useHash: useHashedUrl, history: props.history, }); - const sessionStorageStateStorage = createSessionStorageStateStorage(); - - /** - * Restoring global state: - * State restoration similar to what GlobalState in legacy world did - * It restores state both from url and from session storage - */ - const globalStateKey = `_g`; - const globalStateFromInitialUrl = getStateFromKbnUrl( - globalStateKey, - initialAppUrl.current - ); - const globalStateFromCurrentUrl = kbnUrlStateStorage.get(globalStateKey); - const globalStateFromSessionStorage = sessionStorageStateStorage.get( - globalStateKey - ); + // key to store state in the storage. In this case in the key of the query param in the URL + const appStateKey = `_todo`; - const initialGlobalState: GlobalState = { - ...defaultGlobalState, - ...globalStateFromCurrentUrl, - ...globalStateFromSessionStorage, - ...globalStateFromInitialUrl, - }; - globalStateContainer.set(initialGlobalState); - kbnUrlStateStorage.set(globalStateKey, initialGlobalState, { replace: true }); - sessionStorageStateStorage.set(globalStateKey, initialGlobalState); - - /** - * Restoring app local state: - * State restoration similar to what AppState in legacy world did - * It restores state both from url - */ - const appStateKey = `_todo-${props.appInstanceId}`; + // take care of initial state. Make sure state in memory is the same as in the URL before starting any syncing const initialAppState: TodoState = getStateFromKbnUrl(appStateKey, initialAppUrl.current) || kbnUrlStateStorage.get(appStateKey) || defaultState; - container.set(initialAppState); + stateContainer.set(initialAppState); kbnUrlStateStorage.set(appStateKey, initialAppState, { replace: true }); - // start syncing only when made sure, that state in synced - const { stop, start } = syncStates([ - { - stateContainer: withDefaultState(container, defaultState), - storageKey: appStateKey, - stateStorage: kbnUrlStateStorage, - }, - { - stateContainer: withDefaultState(globalStateContainer, defaultGlobalState), - storageKey: globalStateKey, - stateStorage: kbnUrlStateStorage, - }, - { - stateContainer: withDefaultState(globalStateContainer, defaultGlobalState), - storageKey: globalStateKey, - stateStorage: sessionStorageStateStorage, - }, - ]); + // start syncing state between state container and the URL + const { stop, start } = syncState({ + stateContainer: withDefaultState(stateContainer, defaultState), + storageKey: appStateKey, + stateStorage: kbnUrlStateStorage, + }); start(); return () => { stop(); - - // reset state containers - container.set(defaultState); - globalStateContainer.set(defaultGlobalState); }; - }, [props.appInstanceId, props.history, useHashedUrl]); + }, [stateContainer, props.history, useHashedUrl]); return ( - - - - - - -

- State sync example. Instance: ${props.appInstanceId}. {props.appTitle} -

-
- setUseHashedUrl(!useHashedUrl)}> - {useHashedUrl ? 'Use Expanded State' : 'Use Hashed State'} - -
-
- - - - - - - - - - - - - - - -
-
-
+ + + + +

{props.appTitle}

+
+ + +

+ This is a simple TODO app that uses state containers and state syncing utils. It + stores state in the URL similar like Discover or Dashboard apps do.
+ Play with the app and see how the state is persisted in the URL. +
Undo/Redo with browser history also works. +

+
+
+
+ + + + + + + + + + + + + + + +

Most of kibana apps persist state in the URL in two ways:

+
    +
  1. Expanded state in rison format
  2. +
  3. + Just a state hash.
    + In the URL only the hash from the state is stored. The state itself is stored in + the sessionStorage. See `state:storeInSessionStorage` advanced option for more + context. +
  4. +
+

You can switch between these two mods:

+
+ + setUseHashedUrl(!useHashedUrl)}> + {useHashedUrl ? 'Use Expanded State' : 'Use Hashed State'} + +
+
+
); }; diff --git a/examples/state_containers_examples/public/with_data_services/components/app.tsx b/examples/state_containers_examples/public/with_data_services/app.tsx similarity index 58% rename from examples/state_containers_examples/public/with_data_services/components/app.tsx rename to examples/state_containers_examples/public/with_data_services/app.tsx index b526032a5becb9..fc84e1e952aaa9 100644 --- a/examples/state_containers_examples/public/with_data_services/components/app.tsx +++ b/examples/state_containers_examples/public/with_data_services/app.tsx @@ -6,50 +6,47 @@ * Public License, v 1. */ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { History } from 'history'; -import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; import { Router } from 'react-router-dom'; import { EuiFieldText, - EuiPage, EuiPageBody, EuiPageContent, EuiPageHeader, + EuiText, EuiTitle, } from '@elastic/eui'; +import { CoreStart } from 'kibana/public'; +import { NavigationPublicPluginStart } from '../../../../src/plugins/navigation/public'; -import { CoreStart } from '../../../../../src/core/public'; -import { NavigationPublicPluginStart } from '../../../../../src/plugins/navigation/public'; import { connectToQueryState, - syncQueryStateWithUrl, DataPublicPluginStart, - IIndexPattern, - QueryState, - Filter, esFilters, + Filter, + IIndexPattern, Query, -} from '../../../../../src/plugins/data/public'; + QueryState, + syncQueryStateWithUrl, +} from '../../../../src/plugins/data/public'; import { - BaseState, BaseStateContainer, createStateContainer, - createStateContainerReactHelpers, IKbnUrlStateStorage, - ReduxLikeStateContainer, syncState, -} from '../../../../../src/plugins/kibana_utils/public'; -import { PLUGIN_ID, PLUGIN_NAME } from '../../../common'; + useContainerState, +} from '../../../../src/plugins/kibana_utils/public'; +import { ExampleLink, StateContainersExamplesPage } from '../common/example_page'; interface StateDemoAppDeps { - notifications: CoreStart['notifications']; - http: CoreStart['http']; + navigateToApp: CoreStart['application']['navigateToApp']; navigation: NavigationPublicPluginStart; data: DataPublicPluginStart; history: History; kbnUrlStateStorage: IKbnUrlStateStorage; + exampleLinks: ExampleLink[]; } interface AppState { @@ -61,85 +58,74 @@ const defaultAppState: AppState = { name: '', filters: [], }; -const { - Provider: AppStateContainerProvider, - useState: useAppState, - useContainer: useAppStateContainer, -} = createStateContainerReactHelpers>(); -const App = ({ navigation, data, history, kbnUrlStateStorage }: StateDemoAppDeps) => { - const appStateContainer = useAppStateContainer(); - const appState = useAppState(); +export const App = ({ + navigation, + data, + history, + kbnUrlStateStorage, + exampleLinks, + navigateToApp, +}: StateDemoAppDeps) => { + const appStateContainer = useMemo(() => createStateContainer(defaultAppState), []); + const appState = useContainerState(appStateContainer); useGlobalStateSyncing(data.query, kbnUrlStateStorage); useAppStateSyncing(appStateContainer, data.query, kbnUrlStateStorage); const indexPattern = useIndexPattern(data); if (!indexPattern) - return
No index pattern found. Please create an index patter before loading...
; + return ( +
+ No index pattern found. Please create an index pattern before trying this example... +
+ ); - // Render the application DOM. // Note that `navigation.ui.TopNavMenu` is a stateful component exported on the `navigation` plugin's start contract. return ( - - + + <> - - - - - -

- -

-
-
- - appStateContainer.set({ ...appState, name: e.target.value })} - aria-label="My name" - /> - -
-
+ + + +

Integration with search bar

+
+
+ +

+ This examples shows how you can use state containers, state syncing utils and + helpers from data plugin to sync your app state and search bar state with the URL. +

+
+ + + + +

+ In addition to state from query bar also sync your arbitrary application state: +

+
+ appStateContainer.set({ ...appState, name: e.target.value })} + aria-label="My name" + /> +
+
-
-
- ); -}; - -export const StateDemoApp = (props: StateDemoAppDeps) => { - const appStateContainer = useCreateStateContainer(defaultAppState); - - return ( - - - + + ); }; -function useCreateStateContainer( - defaultState: State -): ReduxLikeStateContainer { - const stateContainerRef = useRef | null>(null); - if (!stateContainerRef.current) { - stateContainerRef.current = createStateContainer(defaultState); - } - return stateContainerRef.current; -} - function useIndexPattern(data: DataPublicPluginStart) { const [indexPattern, setIndexPattern] = useState(); useEffect(() => { diff --git a/examples/state_containers_examples/public/with_data_services/application.tsx b/examples/state_containers_examples/public/with_data_services/application.tsx index d50c203a2a0797..4235446dd06e05 100644 --- a/examples/state_containers_examples/public/with_data_services/application.tsx +++ b/examples/state_containers_examples/public/with_data_services/application.tsx @@ -10,24 +10,26 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { AppMountParameters, CoreStart } from '../../../../src/core/public'; import { AppPluginDependencies } from './types'; -import { StateDemoApp } from './components/app'; +import { App } from './app'; import { createKbnUrlStateStorage } from '../../../../src/plugins/kibana_utils/public/'; +import { ExampleLink } from '../common/example_page'; export const renderApp = ( - { notifications, http }: CoreStart, + { notifications, application }: CoreStart, { navigation, data }: AppPluginDependencies, - { element, history }: AppMountParameters + { element, history }: AppMountParameters, + { exampleLinks }: { exampleLinks: ExampleLink[] } ) => { const kbnUrlStateStorage = createKbnUrlStateStorage({ useHash: false, history }); ReactDOM.render( - , element ); diff --git a/examples/state_containers_examples/server/index.ts b/examples/state_containers_examples/server/index.ts deleted file mode 100644 index 6ae5d240667115..00000000000000 --- a/examples/state_containers_examples/server/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * and the Server Side Public License, v 1; you may not use this file except in - * compliance with, at your election, the Elastic License or the Server Side - * Public License, v 1. - */ - -import { PluginInitializerContext } from '../../../src/core/server'; -import { StateDemoServerPlugin } from './plugin'; - -export function plugin(initializerContext: PluginInitializerContext) { - return new StateDemoServerPlugin(initializerContext); -} - -export { StateDemoServerPlugin as Plugin }; -export * from '../common'; diff --git a/examples/state_containers_examples/server/plugin.ts b/examples/state_containers_examples/server/plugin.ts deleted file mode 100644 index 04ab4d7a0fede8..00000000000000 --- a/examples/state_containers_examples/server/plugin.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * and the Server Side Public License, v 1; you may not use this file except in - * compliance with, at your election, the Elastic License or the Server Side - * Public License, v 1. - */ - -import { - PluginInitializerContext, - CoreSetup, - CoreStart, - Plugin, - Logger, -} from '../../../src/core/server'; - -import { StateDemoPluginSetup, StateDemoPluginStart } from './types'; -import { defineRoutes } from './routes'; - -export class StateDemoServerPlugin implements Plugin { - private readonly logger: Logger; - - constructor(initializerContext: PluginInitializerContext) { - this.logger = initializerContext.logger.get(); - } - - public setup(core: CoreSetup) { - this.logger.debug('State_demo: Ssetup'); - const router = core.http.createRouter(); - - // Register server side APIs - defineRoutes(router); - - return {}; - } - - public start(core: CoreStart) { - this.logger.debug('State_demo: Started'); - return {}; - } - - public stop() {} -} - -export { StateDemoServerPlugin as Plugin }; diff --git a/examples/state_containers_examples/server/routes/index.ts b/examples/state_containers_examples/server/routes/index.ts deleted file mode 100644 index f7c7a6abe88086..00000000000000 --- a/examples/state_containers_examples/server/routes/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * and the Server Side Public License, v 1; you may not use this file except in - * compliance with, at your election, the Elastic License or the Server Side - * Public License, v 1. - */ - -import { IRouter } from '../../../../src/core/server'; - -export function defineRoutes(router: IRouter) { - router.get( - { - path: '/api/state_demo/example', - validate: false, - }, - async (context, request, response) => { - return response.ok({ - body: { - time: new Date().toISOString(), - }, - }); - } - ); -} diff --git a/examples/state_containers_examples/server/types.ts b/examples/state_containers_examples/server/types.ts deleted file mode 100644 index 86dc8d556e4c1c..00000000000000 --- a/examples/state_containers_examples/server/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * and the Server Side Public License, v 1; you may not use this file except in - * compliance with, at your election, the Elastic License or the Server Side - * Public License, v 1. - */ - -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface StateDemoPluginSetup {} -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface StateDemoPluginStart {} diff --git a/src/plugins/kibana_react/kibana.json b/src/plugins/kibana_react/kibana.json index c05490c3499170..f2f0da53e62803 100644 --- a/src/plugins/kibana_react/kibana.json +++ b/src/plugins/kibana_react/kibana.json @@ -2,6 +2,5 @@ "id": "kibanaReact", "version": "kibana", "ui": true, - "server": false, - "requiredBundles": ["kibanaUtils"] + "server": false } diff --git a/src/plugins/kibana_react/public/index.ts b/src/plugins/kibana_react/public/index.ts index 4ec96f1db81995..c99da5e9b36b83 100644 --- a/src/plugins/kibana_react/public/index.ts +++ b/src/plugins/kibana_react/public/index.ts @@ -21,7 +21,6 @@ export { ValidatedDualRange, Value } from './validated_range'; export * from './notifications'; export { Markdown, MarkdownSimple } from './markdown'; export { reactToUiComponent, uiToReactComponent } from './adapters'; -export { useUrlTracker } from './use_url_tracker'; export { toMountPoint, MountPointPortal } from './util'; export { RedirectAppLinks } from './app_links'; diff --git a/src/plugins/kibana_react/public/use_url_tracker/index.ts b/src/plugins/kibana_react/public/use_url_tracker/index.ts deleted file mode 100644 index 7ba21ddafaef21..00000000000000 --- a/src/plugins/kibana_react/public/use_url_tracker/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * and the Server Side Public License, v 1; you may not use this file except in - * compliance with, at your election, the Elastic License or the Server Side - * Public License, v 1. - */ - -export { useUrlTracker } from './use_url_tracker'; diff --git a/src/plugins/kibana_react/public/use_url_tracker/use_url_tracker.test.tsx b/src/plugins/kibana_react/public/use_url_tracker/use_url_tracker.test.tsx deleted file mode 100644 index ed3eca04943a6f..00000000000000 --- a/src/plugins/kibana_react/public/use_url_tracker/use_url_tracker.test.tsx +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * and the Server Side Public License, v 1; you may not use this file except in - * compliance with, at your election, the Elastic License or the Server Side - * Public License, v 1. - */ - -import { renderHook } from '@testing-library/react-hooks'; -import { useUrlTracker } from './use_url_tracker'; -import { StubBrowserStorage } from '@kbn/test/jest'; -import { createMemoryHistory } from 'history'; - -describe('useUrlTracker', () => { - const key = 'key'; - let storage = new StubBrowserStorage(); - let history = createMemoryHistory(); - beforeEach(() => { - storage = new StubBrowserStorage(); - history = createMemoryHistory(); - }); - - it('should track history changes and save them to storage', () => { - expect(storage.getItem(key)).toBeNull(); - const { unmount } = renderHook(() => { - useUrlTracker(key, history, () => false, storage); - }); - expect(storage.getItem(key)).toBe('/'); - history.push('/change'); - expect(storage.getItem(key)).toBe('/change'); - unmount(); - history.push('/other-change'); - expect(storage.getItem(key)).toBe('/change'); - }); - - it('by default should restore initial url', () => { - storage.setItem(key, '/change'); - renderHook(() => { - useUrlTracker(key, history, undefined, storage); - }); - expect(history.location.pathname).toBe('/change'); - }); - - it('should restore initial url if shouldRestoreUrl cb returns true', () => { - storage.setItem(key, '/change'); - renderHook(() => { - useUrlTracker(key, history, () => true, storage); - }); - expect(history.location.pathname).toBe('/change'); - }); - - it('should not restore initial url if shouldRestoreUrl cb returns false', () => { - storage.setItem(key, '/change'); - renderHook(() => { - useUrlTracker(key, history, () => false, storage); - }); - expect(history.location.pathname).toBe('/'); - }); -}); diff --git a/src/plugins/kibana_react/public/use_url_tracker/use_url_tracker.tsx b/src/plugins/kibana_react/public/use_url_tracker/use_url_tracker.tsx deleted file mode 100644 index 5f3caf03ae447a..00000000000000 --- a/src/plugins/kibana_react/public/use_url_tracker/use_url_tracker.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * and the Server Side Public License, v 1; you may not use this file except in - * compliance with, at your election, the Elastic License or the Server Side - * Public License, v 1. - */ - -import { History } from 'history'; -import { useLayoutEffect } from 'react'; -import { createUrlTracker } from '../../../kibana_utils/public/'; - -/** - * State management url_tracker in react hook form - * - * Replicates what src/legacy/ui/public/chrome/api/nav.ts did - * Persists the url in sessionStorage so it could be restored if navigated back to the app - * - * @param key - key to use in storage - * @param history - history instance to use - * @param shouldRestoreUrl - cb if url should be restored - * @param storage - storage to use. window.sessionStorage is default - */ -export function useUrlTracker( - key: string, - history: History, - shouldRestoreUrl: (urlToRestore: string) => boolean = () => true, - storage: Storage = sessionStorage -) { - useLayoutEffect(() => { - const urlTracker = createUrlTracker(key, storage); - const urlToRestore = urlTracker.getTrackedUrl(); - if (urlToRestore && shouldRestoreUrl(urlToRestore)) { - history.replace(urlToRestore); - } - const stopTrackingUrl = urlTracker.startTrackingUrl(history); - return () => { - stopTrackingUrl(); - }; - }, [key, history]); -} From d1e3ee98e5b23aa73e9de895c7af0dedd82d4921 Mon Sep 17 00:00:00 2001 From: Chris Roberson Date: Thu, 21 Jan 2021 13:08:25 -0500 Subject: [PATCH 02/16] Stop using usingEphemeralEncryptionKey (#88884) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/monitoring/kibana.json | 1 - .../server/lib/elasticsearch/verify_alerting_security.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/x-pack/plugins/monitoring/kibana.json b/x-pack/plugins/monitoring/kibana.json index 501b84dd8825d9..d7784465d4519a 100644 --- a/x-pack/plugins/monitoring/kibana.json +++ b/x-pack/plugins/monitoring/kibana.json @@ -19,7 +19,6 @@ "triggersActionsUi", "alerts", "actions", - "encryptedSavedObjects", "encryptedSavedObjects" ], "server": true, diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/verify_alerting_security.ts b/x-pack/plugins/monitoring/server/lib/elasticsearch/verify_alerting_security.ts index c8aa730dd47748..aff7c4edb51747 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/verify_alerting_security.ts +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/verify_alerting_security.ts @@ -43,7 +43,7 @@ export class AlertingSecurity { return { isSufficientlySecure: !isSecurityEnabled || (isSecurityEnabled && isTLSEnabled), - hasPermanentEncryptionKey: !encryptedSavedObjects?.usingEphemeralEncryptionKey, + hasPermanentEncryptionKey: Boolean(encryptedSavedObjects), }; }; } From 933d1b1471817114a33d9d1bedfb3a5e81adbd1a Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Thu, 21 Jan 2021 12:10:59 -0600 Subject: [PATCH 03/16] skip "run cancels expired tasks prior to running new tasks" --- x-pack/plugins/task_manager/server/task_pool.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/task_manager/server/task_pool.test.ts b/x-pack/plugins/task_manager/server/task_pool.test.ts index 9161bbf3c28a56..324e376c32d95a 100644 --- a/x-pack/plugins/task_manager/server/task_pool.test.ts +++ b/x-pack/plugins/task_manager/server/task_pool.test.ts @@ -203,7 +203,7 @@ describe('TaskPool', () => { sinon.assert.calledOnce(secondRun); }); - test('run cancels expired tasks prior to running new tasks', async () => { + test.skip('run cancels expired tasks prior to running new tasks', async () => { const logger = loggingSystemMock.create().get(); const pool = new TaskPool({ maxWorkers$: of(2), From eaab783410f62e5b2b7e4b5c1f48da080dd177a1 Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Thu, 21 Jan 2021 12:41:51 -0600 Subject: [PATCH 04/16] [Workplace Search] Add unit tests for top-level Sources components (#88918) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add full source mocks The overview page recieves heavily annotated source data for display. This extends the existing mocks * Refactor for easier readability Uses optional chaining. Hide whitespace changes for easier reviewing of this commit * Remove conditionals The false case will never be true here because the line above only renders when there is a summary. Around line 109: ``` {!!summary && ``` * Refactor GroupsSummary to variable It was challenging to test the null in the original implementation so I refactored to cloer match the way we do this in other places by making the conditional rendering inline, rather than `null` in a function. * Remove unused const * Add overview test-subj attrs * Add overview unit tests * Add tests for SourceAdded * Move meta to shared mock * Add tests for SourceContent * Add tests for SourceInfoCard * Move redirect logic from component to logic file We had this weird callback passing to trigger a redirect and we are already redirecting in the logic file for other things so I simplified this to have the logic file do the redirecting and not have to pass the callback around, which is hard to test and unnecessary complexity. Also using the KibanaLogic navigateToUrl instead of history.push # Conflicts: # x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts * Add tests for SourceSettings * Add tests for SourceSubNav * I am the typo king 🤴🏼Prove me wrong. Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../__mocks__/content_sources.mock.ts | 57 ++++++ .../workplace_search/__mocks__/meta.mock.ts | 16 ++ .../components/overview.test.tsx | 130 ++++++++++++ .../content_sources/components/overview.tsx | 102 +++++----- .../components/source_added.test.tsx | 54 +++++ .../components/source_content.test.tsx | 186 ++++++++++++++++++ .../components/source_content.tsx | 2 +- .../components/source_info_card.test.tsx | 33 ++++ .../components/source_settings.test.tsx | 108 ++++++++++ .../components/source_settings.tsx | 9 +- .../components/source_sub_nav.test.tsx | 38 ++++ .../views/content_sources/source_logic.ts | 14 +- .../views/groups/groups.test.tsx | 10 +- 13 files changed, 678 insertions(+), 81 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/meta.mock.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_added.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_info_card.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.test.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/content_sources.mock.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/content_sources.mock.ts index 3cd84d90d9a863..0e0d1fa8640339 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/content_sources.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/content_sources.mock.ts @@ -6,6 +6,7 @@ import { mergeServerAndStaticData } from '../views/content_sources/sources_logic'; import { staticSourceData } from '../views/content_sources/source_data'; +import { groups } from './groups.mock'; export const contentSources = [ { @@ -38,6 +39,62 @@ export const contentSources = [ }, ]; +export const fullContentSources = [ + { + ...contentSources[0], + activities: [ + { + details: ['detail'], + event: 'this is an event', + time: '2021-01-20', + status: 'syncing', + }, + ], + details: [ + { + title: 'My Thing', + description: 'This is a thing.', + }, + ], + summary: [ + { + count: 1, + type: 'summary', + }, + ], + groups, + custom: false, + accessToken: '123token', + urlField: 'myLink', + titleField: 'heading', + licenseSupportsPermissions: true, + serviceTypeSupportsPermissions: true, + indexPermissions: true, + hasPermissions: true, + urlFieldIsLinkable: true, + createdAt: '2021-01-20', + serviceName: 'myService', + }, + { + ...contentSources[1], + activities: [], + details: [], + summary: [], + groups: [], + custom: true, + accessToken: '123token', + urlField: 'url', + titleField: 'title', + licenseSupportsPermissions: false, + serviceTypeSupportsPermissions: false, + indexPermissions: false, + hasPermissions: false, + urlFieldIsLinkable: false, + createdAt: '2021-01-20', + serviceName: 'custom', + }, +]; + export const configuredSources = [ { serviceType: 'gmail', diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/meta.mock.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/meta.mock.ts new file mode 100644 index 00000000000000..e596ea5d7e9481 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/meta.mock.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DEFAULT_META } from '../../shared/constants'; + +export const mockMeta = { + ...DEFAULT_META, + page: { + current: 1, + total_results: 50, + total_pages: 5, + }, +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.test.tsx new file mode 100644 index 00000000000000..826e863533074d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.test.tsx @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/shallow_useeffect.mock'; + +import { setMockValues } from '../../../../__mocks__'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { EuiEmptyPrompt, EuiPanel, EuiTable } from '@elastic/eui'; + +import { fullContentSources } from '../../../__mocks__/content_sources.mock'; + +import { Loading } from '../../../../shared/loading'; +import { ComponentLoader } from '../../../components/shared/component_loader'; + +import { Overview } from './overview'; + +describe('Overview', () => { + const contentSource = fullContentSources[0]; + const dataLoading = false; + const isOrganization = true; + + const mockValues = { + contentSource, + dataLoading, + isOrganization, + }; + + beforeEach(() => { + setMockValues({ ...mockValues }); + }); + + it('renders', () => { + const wrapper = shallow(); + const documentSummary = wrapper.find('[data-test-subj="DocumentSummary"]').dive(); + + expect(documentSummary).toHaveLength(1); + expect(documentSummary.find('[data-test-subj="DocumentSummaryRow"]')).toHaveLength(1); + }); + + it('returns Loading when loading', () => { + setMockValues({ ...mockValues, dataLoading: true }); + const wrapper = shallow(); + + expect(wrapper.find(Loading)).toHaveLength(1); + }); + + it('renders ComponentLoader when loading', () => { + setMockValues({ + ...mockValues, + contentSource: { + ...fullContentSources[1], + summary: null, + }, + }); + + const wrapper = shallow(); + const documentSummary = wrapper.find('[data-test-subj="DocumentSummary"]').dive(); + + expect(documentSummary.find(ComponentLoader)).toHaveLength(1); + }); + + it('handles empty states', () => { + setMockValues({ ...mockValues, contentSource: fullContentSources[1] }); + const wrapper = shallow(); + const documentSummary = wrapper.find('[data-test-subj="DocumentSummary"]').dive(); + const activitySummary = wrapper.find('[data-test-subj="ActivitySummary"]').dive(); + + expect(documentSummary.find(EuiEmptyPrompt)).toHaveLength(1); + expect(activitySummary.find(EuiEmptyPrompt)).toHaveLength(1); + expect(wrapper.find('[data-test-subj="GroupsSummary"]')).toHaveLength(0); + }); + + it('renders activity table', () => { + const wrapper = shallow(); + const activitySummary = wrapper.find('[data-test-subj="ActivitySummary"]').dive(); + + expect(activitySummary.find(EuiTable)).toHaveLength(1); + }); + + it('renders GroupsSummary', () => { + const wrapper = shallow(); + const groupsSummary = wrapper.find('[data-test-subj="GroupsSummary"]').dive(); + + expect(groupsSummary.find('[data-test-subj="SourceGroupLink"]')).toHaveLength(1); + }); + + it('renders DocumentationCallout', () => { + setMockValues({ ...mockValues, contentSource: fullContentSources[1] }); + const wrapper = shallow(); + const documentationCallout = wrapper.find('[data-test-subj="DocumentationCallout"]').dive(); + + expect(documentationCallout.find(EuiPanel)).toHaveLength(1); + }); + + it('renders PermissionsStatus', () => { + setMockValues({ + ...mockValues, + contentSource: { + ...fullContentSources[0], + serviceTypeSupportsPermissions: true, + hasPermissions: false, + }, + }); + + const wrapper = shallow(); + + expect(wrapper.find('[data-test-subj="PermissionsStatus"]')).toHaveLength(1); + }); + + it('renders DocumentPermissionsDisabled', () => { + setMockValues({ + ...mockValues, + contentSource: { + ...fullContentSources[1], + serviceTypeSupportsPermissions: true, + custom: false, + }, + }); + + const wrapper = shallow(); + + expect(wrapper.find('[data-test-subj="DocumentPermissionsDisabled"]')).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx index 71d79b4b2a0827..a0797305de6cae 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx @@ -55,7 +55,6 @@ export const Overview: React.FC = () => { const { id, summary, - documentCount, activities, groups, details, @@ -72,24 +71,22 @@ export const Overview: React.FC = () => { const DocumentSummary = () => { let totalDocuments = 0; - const tableContent = - summary && - summary.map((item, index) => { - totalDocuments += item.count; - return ( - item.count > 0 && ( - - {item.type} - {item.count.toLocaleString('en-US')} - - ) - ); - }); + const tableContent = summary?.map((item, index) => { + totalDocuments += item.count; + return ( + item.count > 0 && ( + + {item.type} + {item.count.toLocaleString('en-US')} + + ) + ); + }); const emptyState = ( <> - + No content yet} iconType="documents" @@ -121,14 +118,10 @@ export const Overview: React.FC = () => { {tableContent} - {summary ? Total documents : 'Documents'} + Total documents - {summary ? ( - {totalDocuments.toLocaleString('en-US')} - ) : ( - parseInt(documentCount, 10).toLocaleString('en-US') - )} + {totalDocuments.toLocaleString('en-US')} @@ -142,7 +135,7 @@ export const Overview: React.FC = () => { const emptyState = ( <> - + There is no recent activity} iconType="clock" @@ -202,31 +195,29 @@ export const Overview: React.FC = () => { ); }; - const GroupsSummary = () => { - return !groups.length ? null : ( - <> - -

Group Access

-
- - - {groups.map((group, index) => ( - - - - {group.name} - - - - ))} - - - ); - }; + const groupsSummary = ( + <> + +

Group Access

+
+ + + {groups.map((group, index) => ( + + + + {group.name} + + + + ))} + + + ); const detailsSummary = ( <> @@ -285,7 +276,7 @@ export const Overview: React.FC = () => {

Document-level permissions

- + @@ -333,7 +324,7 @@ export const Overview: React.FC = () => { ); const permissionsStatus = ( - +
Status @@ -426,20 +417,18 @@ export const Overview: React.FC = () => { - + {!isFederatedSource && ( - + )} - - - + {groups.length > 0 && groupsSummary} {details.length > 0 && {detailsSummary}} {!custom && serviceTypeSupportsPermissions && ( <> @@ -458,7 +447,10 @@ export const Overview: React.FC = () => { {sourceStatus} {credentials} - +

Learn more diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_added.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_added.test.tsx new file mode 100644 index 00000000000000..d29995484540c3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_added.test.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/shallow_useeffect.mock'; + +import { setMockValues, setMockActions, mockFlashMessageHelpers } from '../../../../__mocks__'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { Redirect, useLocation } from 'react-router-dom'; + +import { SourceAdded } from './source_added'; + +describe('SourceAdded', () => { + const { setErrorMessage } = mockFlashMessageHelpers; + const setAddedSource = jest.fn(); + + beforeEach(() => { + setMockActions({ setAddedSource }); + setMockValues({ isOrganization: true }); + }); + + it('renders', () => { + const search = '?name=foo&serviceType=custom&indexPermissions=false'; + (useLocation as jest.Mock).mockImplementationOnce(() => ({ search })); + const wrapper = shallow(); + + expect(wrapper.find(Redirect)).toHaveLength(1); + expect(setAddedSource).toHaveBeenCalled(); + }); + + describe('hasError', () => { + it('passes default error to server', () => { + const search = '?name=foo&hasError=true&serviceType=custom&indexPermissions=false'; + (useLocation as jest.Mock).mockImplementationOnce(() => ({ search })); + shallow(); + + expect(setErrorMessage).toHaveBeenCalledWith('foo failed to connect.'); + }); + + it('passes custom error to server', () => { + const search = + '?name=foo&hasError=true&serviceType=custom&indexPermissions=false&errorMessages[]=custom error'; + (useLocation as jest.Mock).mockImplementationOnce(() => ({ search })); + shallow(); + + expect(setErrorMessage).toHaveBeenCalledWith('custom error'); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.test.tsx new file mode 100644 index 00000000000000..c445a7aec04f65 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.test.tsx @@ -0,0 +1,186 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/shallow_useeffect.mock'; + +import { setMockValues, setMockActions } from '../../../../__mocks__'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { + EuiTable, + EuiButton, + EuiButtonEmpty, + EuiEmptyPrompt, + EuiFieldSearch, + EuiLink, +} from '@elastic/eui'; + +import { mockMeta } from '../../../__mocks__/meta.mock'; +import { fullContentSources } from '../../../__mocks__/content_sources.mock'; + +import { DEFAULT_META } from '../../../../shared/constants'; +import { ComponentLoader } from '../../../components/shared/component_loader'; +import { Loading } from '../../../../../applications/shared/loading'; +import { TablePaginationBar } from '../../../components/shared/table_pagination_bar'; + +import { SourceContent } from './source_content'; + +describe('SourceContent', () => { + const setActivePage = jest.fn(); + const searchContentSourceDocuments = jest.fn(); + const resetSourceState = jest.fn(); + const setContentFilterValue = jest.fn(); + + const mockValues = { + contentSource: fullContentSources[0], + contentMeta: mockMeta, + contentItems: [ + { + id: '1234', + last_updated: '2021-01-21', + }, + { + id: '1235', + last_updated: '2021-01-20', + }, + ], + contentFilterValue: '', + dataLoading: false, + sectionLoading: false, + isOrganization: true, + }; + + beforeEach(() => { + setMockActions({ + setActivePage, + searchContentSourceDocuments, + resetSourceState, + setContentFilterValue, + }); + setMockValues({ ...mockValues }); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiTable)).toHaveLength(1); + }); + + it('returns Loading when loading', () => { + setMockValues({ ...mockValues, dataLoading: true }); + const wrapper = shallow(); + + expect(wrapper.find(Loading)).toHaveLength(1); + }); + + it('returns ComponentLoader when section loading', () => { + setMockValues({ ...mockValues, sectionLoading: true }); + const wrapper = shallow(); + + expect(wrapper.find(ComponentLoader)).toHaveLength(1); + }); + + describe('empty states', () => { + beforeEach(() => { + setMockValues({ ...mockValues, contentMeta: DEFAULT_META }); + }); + it('renders', () => { + setMockValues({ ...mockValues, contentMeta: DEFAULT_META }); + const wrapper = shallow(); + + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + expect(wrapper.find(EuiEmptyPrompt).prop('body')).toBeTruthy(); + expect(wrapper.find(EuiEmptyPrompt).prop('title')).toEqual( +

This source doesn't have any content yet

+ ); + }); + + it('shows custom source docs link', () => { + setMockValues({ + ...mockValues, + contentMeta: DEFAULT_META, + contentSource: { + ...fullContentSources[0], + serviceType: 'google', + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(EuiEmptyPrompt).prop('body')).toBeNull(); + }); + + it('shows correct message when filter value set', () => { + setMockValues({ ...mockValues, contentMeta: DEFAULT_META, contentFilterValue: 'Elastic' }); + const wrapper = shallow(); + + expect(wrapper.find(EuiEmptyPrompt).prop('title')).toEqual( +

No results for 'Elastic'

+ ); + }); + }); + + it('handles page change', () => { + const wrapper = shallow(); + const tablePager = wrapper.find(TablePaginationBar).first(); + tablePager.prop('onChangePage')(3); + + expect(setActivePage).toHaveBeenCalledWith(4); + }); + + it('clears filter value when reset', () => { + setMockValues({ + ...mockValues, + contentSource: { + ...fullContentSources[0], + isFederatedSource: true, + }, + }); + const wrapper = shallow(); + const button = wrapper.find(EuiButtonEmpty); + button.simulate('click'); + + expect(setContentFilterValue).toHaveBeenCalledWith(''); + }); + + it('sets filter value', () => { + setMockValues({ + ...mockValues, + contentSource: { + ...fullContentSources[0], + isFederatedSource: true, + }, + }); + const wrapper = shallow(); + const input = wrapper.find(EuiFieldSearch); + input.simulate('change', { target: { value: 'Query' } }); + const button = wrapper.find(EuiButton); + button.simulate('click'); + + expect(setContentFilterValue).toHaveBeenCalledWith(''); + }); + + describe('URL field link', () => { + it('does not render link when not linkable', () => { + setMockValues({ + ...mockValues, + contentSource: fullContentSources[1], + }); + const wrapper = shallow(); + const fieldCell = wrapper.find('[data-test-subj="URLFieldCell"]'); + + expect(fieldCell.find(EuiLink)).toHaveLength(0); + }); + + it('renders links when linkable', () => { + const wrapper = shallow(); + const fieldCell = wrapper.find('[data-test-subj="URLFieldCell"]'); + + expect(fieldCell.find(EuiLink)).toHaveLength(2); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.tsx index 8d9636ec38e1f8..728d21eb1530fa 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.tsx @@ -122,7 +122,7 @@ export const SourceContent: React.FC = () => { - + {!urlFieldIsLinkable && ( )} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_info_card.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_info_card.test.tsx new file mode 100644 index 00000000000000..0a01fecfc91bb4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_info_card.test.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { EuiBadge, EuiHealth, EuiText, EuiTitle } from '@elastic/eui'; + +import { SourceIcon } from '../../../components/shared/source_icon'; + +import { SourceInfoCard } from './source_info_card'; + +describe('SourceInfoCard', () => { + const props = { + sourceName: 'source', + sourceType: 'custom', + dateCreated: '2021-01-20', + isFederatedSource: true, + }; + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(SourceIcon)).toHaveLength(1); + expect(wrapper.find(EuiBadge)).toHaveLength(1); + expect(wrapper.find(EuiHealth)).toHaveLength(1); + expect(wrapper.find(EuiText)).toHaveLength(3); + expect(wrapper.find(EuiTitle)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.test.tsx new file mode 100644 index 00000000000000..11e74d8246a462 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.test.tsx @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/shallow_useeffect.mock'; + +import { setMockValues, setMockActions } from '../../../../__mocks__'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { EuiConfirmModal } from '@elastic/eui'; + +import { fullContentSources, sourceConfigData } from '../../../__mocks__/content_sources.mock'; + +import { SourceConfigFields } from '../../../components/shared/source_config_fields'; + +import { SourceSettings } from './source_settings'; + +describe('SourceSettings', () => { + const updateContentSource = jest.fn(); + const removeContentSource = jest.fn(); + const resetSourceState = jest.fn(); + const getSourceConfigData = jest.fn(); + const contentSource = fullContentSources[0]; + const buttonLoading = false; + const isOrganization = true; + + const mockValues = { + contentSource, + buttonLoading, + sourceConfigData, + isOrganization, + }; + + beforeEach(() => { + setMockValues({ ...mockValues }); + setMockActions({ + updateContentSource, + removeContentSource, + resetSourceState, + getSourceConfigData, + }); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find('form')).toHaveLength(1); + }); + + it('handles form submission', () => { + const wrapper = shallow(); + + const TEXT = 'name'; + const input = wrapper.find('[data-test-subj="SourceNameInput"]'); + input.simulate('change', { target: { value: TEXT } }); + + const preventDefault = jest.fn(); + wrapper.find('form').simulate('submit', { preventDefault }); + + expect(preventDefault).toHaveBeenCalled(); + expect(updateContentSource).toHaveBeenCalledWith(fullContentSources[0].id, { name: TEXT }); + }); + + it('handles confirmModal submission', () => { + const wrapper = shallow(); + wrapper.find('[data-test-subj="DeleteSourceButton"]').simulate('click'); + + const modal = wrapper.find(EuiConfirmModal); + modal.prop('onConfirm')!({} as any); + modal.prop('onCancel')!({} as any); + + expect(removeContentSource).toHaveBeenCalled(); + }); + + it('falls back when no configured fields sent', () => { + setMockValues({ ...mockValues, sourceConfigData: {} }); + const wrapper = shallow(); + + expect(wrapper.find('form')).toHaveLength(1); + }); + + it('falls back when no consumerKey field sent', () => { + setMockValues({ ...mockValues, sourceConfigData: { configuredFields: { clientId: '123' } } }); + const wrapper = shallow(); + + expect(wrapper.find(SourceConfigFields).prop('consumerKey')).toBeUndefined(); + }); + + it('handles public key use case', () => { + setMockValues({ + ...mockValues, + contentSource: { + ...fullContentSources[0], + serviceType: 'confluence_server', + }, + }); + + const wrapper = shallow(); + + expect(wrapper.find(SourceConfigFields).prop('publicKey')).toEqual( + sourceConfigData.configuredFields.publicKey + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx index 8ca31d184501fa..8d3219be9b02a9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx @@ -6,10 +6,9 @@ import React, { useEffect, useState, ChangeEvent, FormEvent } from 'react'; -import { History } from 'history'; import { useActions, useValues } from 'kea'; import { isEmpty } from 'lodash'; -import { Link, useHistory } from 'react-router-dom'; +import { Link } from 'react-router-dom'; import { EuiButton, @@ -22,8 +21,6 @@ import { EuiFormRow, } from '@elastic/eui'; -import { SOURCES_PATH, getSourcesPath } from '../../../routes'; - import { ContentSection } from '../../../components/shared/content_section'; import { SourceConfigFields } from '../../../components/shared/source_config_fields'; import { ViewContentHeader } from '../../../components/shared/view_content_header'; @@ -35,7 +32,6 @@ import { staticSourceData } from '../source_data'; import { SourceLogic } from '../source_logic'; export const SourceSettings: React.FC = () => { - const history = useHistory() as History; const { updateContentSource, removeContentSource, @@ -83,8 +79,7 @@ export const SourceSettings: React.FC = () => { * modal here and set the button that was clicked to delete to a loading state. */ setModalVisibility(false); - const onSourceRemoved = () => history.push(getSourcesPath(SOURCES_PATH, isOrganization)); - removeContentSource(id, onSourceRemoved); + removeContentSource(id); }; const confirmModal = ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.test.tsx new file mode 100644 index 00000000000000..a90002e5d553e9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.test.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { setMockValues } from '../../../../__mocks__'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { CUSTOM_SERVICE_TYPE } from '../../../constants'; +import { SourceSubNav } from './source_sub_nav'; + +import { SideNavLink } from '../../../../shared/layout'; + +describe('SourceSubNav', () => { + it('renders empty when no group id present', () => { + setMockValues({ contentSource: {} }); + const wrapper = shallow(); + + expect(wrapper.find(SideNavLink)).toHaveLength(0); + }); + + it('renders nav items', () => { + setMockValues({ contentSource: { id: '1' } }); + const wrapper = shallow(); + + expect(wrapper.find(SideNavLink)).toHaveLength(3); + }); + + it('renders custom source nav items', () => { + setMockValues({ contentSource: { id: '1', serviceType: CUSTOM_SERVICE_TYPE } }); + const wrapper = shallow(); + + expect(wrapper.find(SideNavLink)).toHaveLength(5); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts index 9a68d2234e3adb..fe958db9d02326 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts @@ -20,7 +20,7 @@ import { import { DEFAULT_META } from '../../../shared/constants'; import { AppLogic } from '../../app_logic'; -import { NOT_FOUND_PATH } from '../../routes'; +import { NOT_FOUND_PATH, SOURCES_PATH, getSourcesPath } from '../../routes'; import { ContentSourceFullData, Meta, DocumentSummaryItem, SourceContentItem } from '../../types'; export interface SourceActions { @@ -38,10 +38,7 @@ export interface SourceActions { source: { name: string } ): { sourceId: string; source: { name: string } }; resetSourceState(): void; - removeContentSource( - sourceId: string, - successCallback: () => void - ): { sourceId: string; successCallback(): void }; + removeContentSource(sourceId: string): { sourceId: string }; initializeSource(sourceId: string, history: object): { sourceId: string; history: object }; getSourceConfigData(serviceType: string): { serviceType: string }; setButtonNotLoading(): void; @@ -95,9 +92,8 @@ export const SourceLogic = kea>({ initializeFederatedSummary: (sourceId: string) => ({ sourceId }), searchContentSourceDocuments: (sourceId: string) => ({ sourceId }), updateContentSource: (sourceId: string, source: { name: string }) => ({ sourceId, source }), - removeContentSource: (sourceId: string, successCallback: () => void) => ({ + removeContentSource: (sourceId: string) => ({ sourceId, - successCallback, }), getSourceConfigData: (serviceType: string) => ({ serviceType }), resetSourceState: () => true, @@ -245,7 +241,7 @@ export const SourceLogic = kea>({ flashAPIErrors(e); } }, - removeContentSource: async ({ sourceId, successCallback }) => { + removeContentSource: async ({ sourceId }) => { clearFlashMessages(); const { isOrganization } = AppLogic.values; const route = isOrganization @@ -263,7 +259,7 @@ export const SourceLogic = kea>({ } ) ); - successCallback(); + KibanaLogic.values.navigateToUrl(getSourcesPath(SOURCES_PATH, isOrganization)); } catch (e) { flashAPIErrors(e); } finally { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups.test.tsx index 5412924438ca6f..7c746f75ffc941 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups.test.tsx @@ -7,6 +7,7 @@ import '../../../__mocks__/shallow_useeffect.mock'; import { setMockActions, setMockValues } from '../../../__mocks__'; import { groups } from '../../__mocks__/groups.mock'; +import { mockMeta } from '../../__mocks__/meta.mock'; import React from 'react'; import { shallow } from 'enzyme'; @@ -33,15 +34,6 @@ const resetGroups = jest.fn(); const setFilterValue = jest.fn(); const setActivePage = jest.fn(); -const mockMeta = { - ...DEFAULT_META, - page: { - current: 1, - total_results: 50, - total_pages: 5, - }, -}; - const mockSuccessMessage = { type: 'success', message: 'group added', From 88be8a71483ecc4ea697d7944479bde60de36c53 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Thu, 21 Jan 2021 13:42:39 -0500 Subject: [PATCH 05/16] [Fleet] Remove support for shared_id during enrollment (#88897) --- .../plugins/fleet/common/openapi/bundled.json | 1491 ++++++++--------- .../plugins/fleet/common/openapi/bundled.yaml | 1024 ++++++----- .../openapi/components/schemas/agent.yaml | 1 + .../common/openapi/paths/agents@enroll.yaml | 1 + .../fleet/common/types/models/agent.ts | 1 - .../fleet/common/types/rest_spec/agent.ts | 1 - .../fleet/dev_docs/api/agents_enroll.md | 10 - .../fleet/server/routes/agent/handlers.ts | 3 +- .../fleet/server/saved_objects/index.ts | 4 +- .../saved_objects/migrations/to_v7_12_0.ts | 16 + .../fleet/server/services/agents/enroll.ts | 52 +- .../fleet/server/types/rest_spec/agent.ts | 1 + .../apis/agents/enroll.ts | 22 - .../fleet_api_integration/apis/agents/list.ts | 4 +- .../es_archives/fleet/agents/data.json | 6 +- 15 files changed, 1240 insertions(+), 1397 deletions(-) create mode 100644 x-pack/plugins/fleet/server/saved_objects/migrations/to_v7_12_0.ts diff --git a/x-pack/plugins/fleet/common/openapi/bundled.json b/x-pack/plugins/fleet/common/openapi/bundled.json index e9b11a2f5ac837..55c32802c3334d 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.json +++ b/x-pack/plugins/fleet/common/openapi/bundled.json @@ -32,7 +32,7 @@ "items": { "type": "array", "items": { - "$ref": "#/components/schemas/agent_policy" + "$ref": "#/paths/~1agent_policies/post/responses/200/content/application~1json/schema/properties/item" } }, "total": { @@ -59,13 +59,31 @@ "operationId": "agent-policy-list", "parameters": [ { - "$ref": "#/components/parameters/page_size" + "name": "perPage", + "in": "query", + "description": "The number of items to return", + "required": false, + "schema": { + "type": "integer", + "default": 50 + } }, { - "$ref": "#/components/parameters/page_index" + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } }, { - "$ref": "#/components/parameters/kuery" + "name": "kuery", + "in": "query", + "required": false, + "schema": { + "type": "string" + } } ], "description": "" @@ -82,7 +100,58 @@ "type": "object", "properties": { "item": { - "$ref": "#/components/schemas/agent_policy" + "allOf": [ + { + "$ref": "#/paths/~1agent_policies/post/requestBody/content/application~1json/schema" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + }, + "packagePolicies": { + "oneOf": [ + { + "items": { + "type": "string" + } + }, + { + "items": { + "$ref": "#/paths/~1package_policies~1%7BpackagePolicyId%7D/get/responses/200/content/application~1json/schema/properties/item" + } + } + ], + "type": "array" + }, + "updated_on": { + "type": "string", + "format": "date-time" + }, + "updated_by": { + "type": "string" + }, + "revision": { + "type": "number" + }, + "agents": { + "type": "number" + } + }, + "required": [ + "id", + "status" + ] + } + ] } } } @@ -95,7 +164,19 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/new_agent_policy" + "title": "NewAgentPolicy", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "namespace": { + "type": "string" + }, + "description": { + "type": "string" + } + } } } } @@ -103,7 +184,7 @@ "security": [], "parameters": [ { - "$ref": "#/components/parameters/kbn_xsrf" + "$ref": "#/paths/~1setup/post/parameters/0" } ] } @@ -131,7 +212,7 @@ "type": "object", "properties": { "item": { - "$ref": "#/components/schemas/agent_policy" + "$ref": "#/paths/~1agent_policies/post/responses/200/content/application~1json/schema/properties/item" } }, "required": [ @@ -158,7 +239,7 @@ "type": "object", "properties": { "item": { - "$ref": "#/components/schemas/agent_policy" + "$ref": "#/paths/~1agent_policies/post/responses/200/content/application~1json/schema/properties/item" } }, "required": [ @@ -174,14 +255,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/new_agent_policy" + "$ref": "#/paths/~1agent_policies/post/requestBody/content/application~1json/schema" } } } }, "parameters": [ { - "$ref": "#/components/parameters/kbn_xsrf" + "$ref": "#/paths/~1setup/post/parameters/0" } ] } @@ -209,7 +290,7 @@ "type": "object", "properties": { "item": { - "$ref": "#/components/schemas/agent_policy" + "$ref": "#/paths/~1agent_policies/post/responses/200/content/application~1json/schema/properties/item" } }, "required": [ @@ -294,7 +375,7 @@ }, "parameters": [ { - "$ref": "#/components/parameters/kbn_xsrf" + "$ref": "#/paths/~1setup/post/parameters/0" } ] }, @@ -405,7 +486,7 @@ "operationId": "post-fleet-agents-agentId-acks", "parameters": [ { - "$ref": "#/components/parameters/kbn_xsrf" + "$ref": "#/paths/~1setup/post/parameters/0" } ], "requestBody": { @@ -488,7 +569,7 @@ "operationId": "post-fleet-agents-agentId-checkin", "parameters": [ { - "$ref": "#/components/parameters/kbn_xsrf" + "$ref": "#/paths/~1setup/post/parameters/0" } ], "security": [ @@ -503,12 +584,69 @@ "type": "object", "properties": { "local_metadata": { - "$ref": "#/components/schemas/agent_metadata" + "title": "AgentMetadata", + "type": "object" }, "events": { "type": "array", "items": { - "$ref": "#/components/schemas/new_agent_event" + "title": "NewAgentEvent", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "STATE", + "ERROR", + "ACTION_RESULT", + "ACTION" + ] + }, + "subtype": { + "type": "string", + "enum": [ + "RUNNING", + "STARTING", + "IN_PROGRESS", + "CONFIG", + "FAILED", + "STOPPING", + "STOPPED", + "DEGRADED", + "DATA_DUMP", + "ACKNOWLEDGED", + "UNKNOWN" + ] + }, + "timestamp": { + "type": "string" + }, + "message": { + "type": "string" + }, + "payload": { + "type": "string" + }, + "agent_id": { + "type": "string" + }, + "policy_id": { + "type": "string" + }, + "stream_id": { + "type": "string" + }, + "action_id": { + "type": "string" + } + }, + "required": [ + "type", + "subtype", + "timestamp", + "message", + "agent_id" + ] } } } @@ -554,7 +692,7 @@ "operationId": "post-fleet-agents-unenroll", "parameters": [ { - "$ref": "#/components/parameters/kbn_xsrf" + "$ref": "#/paths/~1setup/post/parameters/0" } ], "requestBody": { @@ -593,7 +731,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/upgrade_agent" + "$ref": "#/paths/~1agents~1%7BagentId%7D~1upgrade/post/requestBody/content/application~1json/schema" } } } @@ -603,7 +741,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/upgrade_agent" + "$ref": "#/paths/~1agents~1%7BagentId%7D~1upgrade/post/requestBody/content/application~1json/schema" } } } @@ -612,7 +750,7 @@ "operationId": "post-fleet-agents-upgrade", "parameters": [ { - "$ref": "#/components/parameters/kbn_xsrf" + "$ref": "#/paths/~1setup/post/parameters/0" } ], "requestBody": { @@ -620,7 +758,34 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/upgrade_agent" + "title": "UpgradeAgent", + "oneOf": [ + { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": [ + "version" + ] + }, + { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "source_uri": { + "type": "string" + } + }, + "required": [ + "version" + ] + } + ] } } } @@ -637,7 +802,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/bulk_upgrade_agents" + "$ref": "#/paths/~1agents~1bulk_upgrade/post/requestBody/content/application~1json/schema" } } } @@ -647,7 +812,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/upgrade_agent" + "$ref": "#/paths/~1agents~1%7BagentId%7D~1upgrade/post/requestBody/content/application~1json/schema" } } } @@ -656,7 +821,7 @@ "operationId": "post-fleet-agents-bulk-upgrade", "parameters": [ { - "$ref": "#/components/parameters/kbn_xsrf" + "$ref": "#/paths/~1setup/post/parameters/0" } ], "requestBody": { @@ -664,7 +829,66 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/bulk_upgrade_agents" + "title": "BulkUpgradeAgents", + "oneOf": [ + { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "agents": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "version", + "agents" + ] + }, + { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "source_uri": { + "type": "string" + }, + "agents": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "version", + "agents" + ] + }, + { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "source_uri": { + "type": "string" + }, + "agents": { + "type": "string" + } + }, + "required": [ + "version", + "agents" + ] + } + ] } } } @@ -687,7 +911,106 @@ "type": "string" }, "item": { - "$ref": "#/components/schemas/agent" + "title": "Agent", + "type": "object", + "properties": { + "type": { + "type": "string", + "title": "AgentType", + "enum": [ + "PERMANENT", + "EPHEMERAL", + "TEMPORARY" + ] + }, + "active": { + "type": "boolean" + }, + "enrolled_at": { + "type": "string" + }, + "unenrolled_at": { + "type": "string" + }, + "unenrollment_started_at": { + "type": "string" + }, + "shared_id": { + "type": "string", + "deprecated": true + }, + "access_api_key_id": { + "type": "string" + }, + "default_api_key_id": { + "type": "string" + }, + "policy_id": { + "type": "string" + }, + "policy_revision": { + "type": "number" + }, + "last_checkin": { + "type": "string" + }, + "user_provided_metadata": { + "$ref": "#/paths/~1agents~1%7BagentId%7D~1checkin/post/requestBody/content/application~1json/schema/properties/local_metadata" + }, + "local_metadata": { + "$ref": "#/paths/~1agents~1%7BagentId%7D~1checkin/post/requestBody/content/application~1json/schema/properties/local_metadata" + }, + "id": { + "type": "string" + }, + "current_error_events": { + "type": "array", + "items": { + "title": "AgentEvent", + "allOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] + }, + { + "$ref": "#/paths/~1agents~1%7BagentId%7D~1checkin/post/requestBody/content/application~1json/schema/properties/events/items" + } + ] + } + }, + "access_api_key": { + "type": "string" + }, + "status": { + "type": "string", + "title": "AgentStatus", + "enum": [ + "offline", + "error", + "online", + "inactive", + "warning" + ] + }, + "default_api_key": { + "type": "string" + } + }, + "required": [ + "type", + "active", + "enrolled_at", + "id", + "current_error_events", + "status" + ] } } } @@ -698,7 +1021,7 @@ "operationId": "post-fleet-agents-enroll", "parameters": [ { - "$ref": "#/components/parameters/kbn_xsrf" + "$ref": "#/paths/~1setup/post/parameters/0" } ], "requestBody": { @@ -716,7 +1039,8 @@ ] }, "shared_id": { - "type": "string" + "type": "string", + "deprecated": true }, "metadata": { "type": "object", @@ -726,10 +1050,10 @@ ], "properties": { "local": { - "$ref": "#/components/schemas/agent_metadata" + "$ref": "#/paths/~1agents~1%7BagentId%7D~1checkin/post/requestBody/content/application~1json/schema/properties/local_metadata" }, "user_provided": { - "$ref": "#/components/schemas/agent_metadata" + "$ref": "#/paths/~1agents~1%7BagentId%7D~1checkin/post/requestBody/content/application~1json/schema/properties/local_metadata" } } } @@ -826,7 +1150,7 @@ }, "parameters": [ { - "$ref": "#/components/parameters/kbn_xsrf" + "$ref": "#/paths/~1setup/post/parameters/0" } ] } @@ -846,7 +1170,7 @@ "operationId": "post-fleet-enrollment-api-keys", "parameters": [ { - "$ref": "#/components/parameters/kbn_xsrf" + "$ref": "#/paths/~1setup/post/parameters/0" } ] } @@ -875,7 +1199,7 @@ "operationId": "delete-fleet-enrollment-api-keys-keyId", "parameters": [ { - "$ref": "#/components/parameters/kbn_xsrf" + "$ref": "#/paths/~1setup/post/parameters/0" } ] } @@ -930,7 +1254,51 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/search_result" + "title": "SearchResult", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "download": { + "type": "string" + }, + "icons": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "version": { + "type": "string" + }, + "status": { + "type": "string" + }, + "savedObject": { + "type": "object" + } + }, + "required": [ + "description", + "download", + "icons", + "name", + "path", + "title", + "type", + "version", + "status" + ] } } } @@ -956,7 +1324,182 @@ { "properties": { "response": { - "$ref": "#/components/schemas/package_info" + "title": "PackageInfo", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "title": { + "type": "string" + }, + "version": { + "type": "string" + }, + "readme": { + "type": "string" + }, + "description": { + "type": "string" + }, + "type": { + "type": "string" + }, + "categories": { + "type": "array", + "items": { + "type": "string" + } + }, + "requirement": { + "oneOf": [ + { + "properties": { + "kibana": { + "type": "object", + "properties": { + "versions": { + "type": "string" + } + } + } + } + }, + { + "properties": { + "elasticsearch": { + "type": "object", + "properties": { + "versions": { + "type": "string" + } + } + } + } + } + ], + "type": "object" + }, + "screenshots": { + "type": "array", + "items": { + "type": "object", + "properties": { + "src": { + "type": "string" + }, + "path": { + "type": "string" + }, + "title": { + "type": "string" + }, + "size": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "src", + "path" + ] + } + }, + "icons": { + "type": "array", + "items": { + "type": "string" + } + }, + "assets": { + "type": "array", + "items": { + "type": "string" + } + }, + "internal": { + "type": "boolean" + }, + "format_version": { + "type": "string" + }, + "data_streams": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "name": { + "type": "string" + }, + "release": { + "type": "string" + }, + "ingeset_pipeline": { + "type": "string" + }, + "vars": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "default": { + "type": "string" + } + }, + "required": [ + "name", + "default" + ] + } + }, + "type": { + "type": "string" + }, + "package": { + "type": "string" + } + }, + "required": [ + "title", + "name", + "release", + "ingeset_pipeline", + "type", + "package" + ] + } + }, + "download": { + "type": "string" + }, + "path": { + "type": "string" + }, + "removable": { + "type": "boolean" + } + }, + "required": [ + "name", + "title", + "version", + "description", + "type", + "categories", + "requirement", + "assets", + "format_version", + "download", + "path" + ] } } }, @@ -1043,7 +1586,7 @@ "description": "", "parameters": [ { - "$ref": "#/components/parameters/kbn_xsrf" + "$ref": "#/paths/~1setup/post/parameters/0" } ] }, @@ -1088,7 +1631,7 @@ "operationId": "post-epm-delete-pkgkey", "parameters": [ { - "$ref": "#/components/parameters/kbn_xsrf" + "$ref": "#/paths/~1setup/post/parameters/0" } ] } @@ -1136,7 +1679,7 @@ "operationId": "put-fleet-agents-agentId", "parameters": [ { - "$ref": "#/components/parameters/kbn_xsrf" + "$ref": "#/paths/~1setup/post/parameters/0" } ] }, @@ -1147,7 +1690,7 @@ "operationId": "delete-fleet-agents-agentId", "parameters": [ { - "$ref": "#/components/parameters/kbn_xsrf" + "$ref": "#/paths/~1setup/post/parameters/0" } ] } @@ -1185,7 +1728,7 @@ "items": { "type": "array", "items": { - "$ref": "#/components/schemas/package_policy" + "$ref": "#/paths/~1package_policies~1%7BpackagePolicyId%7D/get/responses/200/content/application~1json/schema/properties/item" } }, "total": { @@ -1223,14 +1766,96 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/new_package_policy" + "title": "NewPackagePolicy", + "type": "object", + "description": "", + "properties": { + "enabled": { + "type": "boolean" + }, + "package": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "name", + "version", + "title" + ] + }, + "namespace": { + "type": "string" + }, + "output_id": { + "type": "string" + }, + "inputs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "processors": { + "type": "array", + "items": { + "type": "string" + } + }, + "streams": { + "type": "array", + "items": {} + }, + "config": { + "type": "object" + }, + "vars": { + "type": "object" + } + }, + "required": [ + "type", + "enabled", + "streams" + ] + } + }, + "policy_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "output_id", + "inputs", + "policy_id", + "name" + ] } } } }, "parameters": [ { - "$ref": "#/components/parameters/kbn_xsrf" + "$ref": "#/paths/~1setup/post/parameters/0" } ] } @@ -1248,7 +1873,31 @@ "type": "object", "properties": { "item": { - "$ref": "#/components/schemas/package_policy" + "title": "PackagePolicy", + "allOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "revision": { + "type": "number" + }, + "inputs": { + "type": "array", + "items": {} + } + }, + "required": [ + "id", + "revision" + ] + }, + { + "$ref": "#/paths/~1package_policies/post/requestBody/content/application~1json/schema" + } + ] } }, "required": [ @@ -1278,7 +1927,20 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/update_package_policy" + "title": "UpdatePackagePolicy", + "allOf": [ + { + "type": "object", + "properties": { + "version": { + "type": "string" + } + } + }, + { + "$ref": "#/paths/~1package_policies/post/requestBody/content/application~1json/schema" + } + ] } } } @@ -1292,7 +1954,7 @@ "type": "object", "properties": { "item": { - "$ref": "#/components/schemas/package_policy" + "$ref": "#/paths/~1package_policies~1%7BpackagePolicyId%7D/get/responses/200/content/application~1json/schema/properties/item" }, "sucess": { "type": "boolean" @@ -1309,7 +1971,7 @@ }, "parameters": [ { - "$ref": "#/components/parameters/kbn_xsrf" + "$ref": "#/paths/~1setup/post/parameters/0" } ] } @@ -1353,7 +2015,12 @@ "operationId": "post-setup", "parameters": [ { - "$ref": "#/components/parameters/kbn_xsrf" + "schema": { + "type": "string" + }, + "in": "header", + "name": "kbn-xsrf", + "required": true } ] } @@ -1377,732 +2044,6 @@ "in": "header", "description": "e.g. Authorization: ApiKey base64AccessApiKey" } - }, - "parameters": { - "page_size": { - "name": "perPage", - "in": "query", - "description": "The number of items to return", - "required": false, - "schema": { - "type": "integer", - "default": 50 - } - }, - "page_index": { - "name": "page", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "default": 1 - } - }, - "kuery": { - "name": "kuery", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - "kbn_xsrf": { - "schema": { - "type": "string" - }, - "in": "header", - "name": "kbn-xsrf", - "required": true - } - }, - "schemas": { - "new_agent_policy": { - "title": "NewAgentPolicy", - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "namespace": { - "type": "string" - }, - "description": { - "type": "string" - } - } - }, - "new_package_policy": { - "title": "NewPackagePolicy", - "type": "object", - "description": "", - "properties": { - "enabled": { - "type": "boolean" - }, - "package": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "version": { - "type": "string" - }, - "title": { - "type": "string" - } - }, - "required": [ - "name", - "version", - "title" - ] - }, - "namespace": { - "type": "string" - }, - "output_id": { - "type": "string" - }, - "inputs": { - "type": "array", - "items": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "processors": { - "type": "array", - "items": { - "type": "string" - } - }, - "streams": { - "type": "array", - "items": {} - }, - "config": { - "type": "object" - }, - "vars": { - "type": "object" - } - }, - "required": [ - "type", - "enabled", - "streams" - ] - } - }, - "policy_id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string" - } - }, - "required": [ - "output_id", - "inputs", - "policy_id", - "name" - ] - }, - "package_policy": { - "title": "PackagePolicy", - "allOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "revision": { - "type": "number" - }, - "inputs": { - "type": "array", - "items": {} - } - }, - "required": [ - "id", - "revision" - ] - }, - { - "$ref": "#/components/schemas/new_package_policy" - } - ] - }, - "agent_policy": { - "allOf": [ - { - "$ref": "#/components/schemas/new_agent_policy" - }, - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "status": { - "type": "string", - "enum": [ - "active", - "inactive" - ] - }, - "packagePolicies": { - "oneOf": [ - { - "items": { - "type": "string" - } - }, - { - "items": { - "$ref": "#/components/schemas/package_policy" - } - } - ], - "type": "array" - }, - "updated_on": { - "type": "string", - "format": "date-time" - }, - "updated_by": { - "type": "string" - }, - "revision": { - "type": "number" - }, - "agents": { - "type": "number" - } - }, - "required": [ - "id", - "status" - ] - } - ] - }, - "agent_metadata": { - "title": "AgentMetadata", - "type": "object" - }, - "new_agent_event": { - "title": "NewAgentEvent", - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "STATE", - "ERROR", - "ACTION_RESULT", - "ACTION" - ] - }, - "subtype": { - "type": "string", - "enum": [ - "RUNNING", - "STARTING", - "IN_PROGRESS", - "CONFIG", - "FAILED", - "STOPPING", - "STOPPED", - "DEGRADED", - "DATA_DUMP", - "ACKNOWLEDGED", - "UNKNOWN" - ] - }, - "timestamp": { - "type": "string" - }, - "message": { - "type": "string" - }, - "payload": { - "type": "string" - }, - "agent_id": { - "type": "string" - }, - "policy_id": { - "type": "string" - }, - "stream_id": { - "type": "string" - }, - "action_id": { - "type": "string" - } - }, - "required": [ - "type", - "subtype", - "timestamp", - "message", - "agent_id" - ] - }, - "upgrade_agent": { - "title": "UpgradeAgent", - "oneOf": [ - { - "type": "object", - "properties": { - "version": { - "type": "string" - } - }, - "required": [ - "version" - ] - }, - { - "type": "object", - "properties": { - "version": { - "type": "string" - }, - "source_uri": { - "type": "string" - } - }, - "required": [ - "version" - ] - } - ] - }, - "bulk_upgrade_agents": { - "title": "BulkUpgradeAgents", - "oneOf": [ - { - "type": "object", - "properties": { - "version": { - "type": "string" - }, - "agents": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "version", - "agents" - ] - }, - { - "type": "object", - "properties": { - "version": { - "type": "string" - }, - "source_uri": { - "type": "string" - }, - "agents": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "version", - "agents" - ] - }, - { - "type": "object", - "properties": { - "version": { - "type": "string" - }, - "source_uri": { - "type": "string" - }, - "agents": { - "type": "string" - } - }, - "required": [ - "version", - "agents" - ] - } - ] - }, - "agent_type": { - "type": "string", - "title": "AgentType", - "enum": [ - "PERMANENT", - "EPHEMERAL", - "TEMPORARY" - ] - }, - "agent_event": { - "title": "AgentEvent", - "allOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string" - } - }, - "required": [ - "id" - ] - }, - { - "$ref": "#/components/schemas/new_agent_event" - } - ] - }, - "agent_status": { - "type": "string", - "title": "AgentStatus", - "enum": [ - "offline", - "error", - "online", - "inactive", - "warning" - ] - }, - "agent": { - "title": "Agent", - "type": "object", - "properties": { - "type": { - "$ref": "#/components/schemas/agent_type" - }, - "active": { - "type": "boolean" - }, - "enrolled_at": { - "type": "string" - }, - "unenrolled_at": { - "type": "string" - }, - "unenrollment_started_at": { - "type": "string" - }, - "shared_id": { - "type": "string" - }, - "access_api_key_id": { - "type": "string" - }, - "default_api_key_id": { - "type": "string" - }, - "policy_id": { - "type": "string" - }, - "policy_revision": { - "type": "number" - }, - "last_checkin": { - "type": "string" - }, - "user_provided_metadata": { - "$ref": "#/components/schemas/agent_metadata" - }, - "local_metadata": { - "$ref": "#/components/schemas/agent_metadata" - }, - "id": { - "type": "string" - }, - "current_error_events": { - "type": "array", - "items": { - "$ref": "#/components/schemas/agent_event" - } - }, - "access_api_key": { - "type": "string" - }, - "status": { - "$ref": "#/components/schemas/agent_status" - }, - "default_api_key": { - "type": "string" - } - }, - "required": [ - "type", - "active", - "enrolled_at", - "id", - "current_error_events", - "status" - ] - }, - "search_result": { - "title": "SearchResult", - "type": "object", - "properties": { - "description": { - "type": "string" - }, - "download": { - "type": "string" - }, - "icons": { - "type": "string" - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "title": { - "type": "string" - }, - "type": { - "type": "string" - }, - "version": { - "type": "string" - }, - "status": { - "type": "string" - }, - "savedObject": { - "type": "object" - } - }, - "required": [ - "description", - "download", - "icons", - "name", - "path", - "title", - "type", - "version", - "status" - ] - }, - "package_info": { - "title": "PackageInfo", - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "title": { - "type": "string" - }, - "version": { - "type": "string" - }, - "readme": { - "type": "string" - }, - "description": { - "type": "string" - }, - "type": { - "type": "string" - }, - "categories": { - "type": "array", - "items": { - "type": "string" - } - }, - "requirement": { - "oneOf": [ - { - "properties": { - "kibana": { - "type": "object", - "properties": { - "versions": { - "type": "string" - } - } - } - } - }, - { - "properties": { - "elasticsearch": { - "type": "object", - "properties": { - "versions": { - "type": "string" - } - } - } - } - } - ], - "type": "object" - }, - "screenshots": { - "type": "array", - "items": { - "type": "object", - "properties": { - "src": { - "type": "string" - }, - "path": { - "type": "string" - }, - "title": { - "type": "string" - }, - "size": { - "type": "string" - }, - "type": { - "type": "string" - } - }, - "required": [ - "src", - "path" - ] - } - }, - "icons": { - "type": "array", - "items": { - "type": "string" - } - }, - "assets": { - "type": "array", - "items": { - "type": "string" - } - }, - "internal": { - "type": "boolean" - }, - "format_version": { - "type": "string" - }, - "data_streams": { - "type": "array", - "items": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "name": { - "type": "string" - }, - "release": { - "type": "string" - }, - "ingeset_pipeline": { - "type": "string" - }, - "vars": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "default": { - "type": "string" - } - }, - "required": [ - "name", - "default" - ] - } - }, - "type": { - "type": "string" - }, - "package": { - "type": "string" - } - }, - "required": [ - "title", - "name", - "release", - "ingeset_pipeline", - "type", - "package" - ] - } - }, - "download": { - "type": "string" - }, - "path": { - "type": "string" - }, - "removable": { - "type": "boolean" - } - }, - "required": [ - "name", - "title", - "version", - "description", - "type", - "categories", - "requirement", - "assets", - "format_version", - "download", - "path" - ] - }, - "update_package_policy": { - "title": "UpdatePackagePolicy", - "allOf": [ - { - "type": "object", - "properties": { - "version": { - "type": "string" - } - } - }, - { - "$ref": "#/components/schemas/new_package_policy" - } - ] - } } }, "security": [ @@ -2110,4 +2051,4 @@ "basicAuth": [] } ] -} \ No newline at end of file +} diff --git a/x-pack/plugins/fleet/common/openapi/bundled.yaml b/x-pack/plugins/fleet/common/openapi/bundled.yaml index 05b5b239dc9809..9461927bb09b86 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.yaml +++ b/x-pack/plugins/fleet/common/openapi/bundled.yaml @@ -25,7 +25,7 @@ paths: items: type: array items: - $ref: '#/components/schemas/agent_policy' + $ref: '#/paths/~1agent_policies/post/responses/200/content/application~1json/schema/properties/item' total: type: number page: @@ -39,9 +39,24 @@ paths: - perPage operationId: agent-policy-list parameters: - - $ref: '#/components/parameters/page_size' - - $ref: '#/components/parameters/page_index' - - $ref: '#/components/parameters/kuery' + - name: perPage + in: query + description: The number of items to return + required: false + schema: + type: integer + default: 50 + - name: page + in: query + required: false + schema: + type: integer + default: 1 + - name: kuery + in: query + required: false + schema: + type: string description: '' post: summary: Agent policy - Create @@ -55,16 +70,53 @@ paths: type: object properties: item: - $ref: '#/components/schemas/agent_policy' + allOf: + - $ref: '#/paths/~1agent_policies/post/requestBody/content/application~1json/schema' + - type: object + properties: + id: + type: string + status: + type: string + enum: + - active + - inactive + packagePolicies: + oneOf: + - items: + type: string + - items: + $ref: '#/paths/~1package_policies~1%7BpackagePolicyId%7D/get/responses/200/content/application~1json/schema/properties/item' + type: array + updated_on: + type: string + format: date-time + updated_by: + type: string + revision: + type: number + agents: + type: number + required: + - id + - status operationId: post-agent-policy requestBody: content: application/json: schema: - $ref: '#/components/schemas/new_agent_policy' + title: NewAgentPolicy + type: object + properties: + name: + type: string + namespace: + type: string + description: + type: string security: [] parameters: - - $ref: '#/components/parameters/kbn_xsrf' + - $ref: '#/paths/~1setup/post/parameters/0' '/agent_policies/{agentPolicyId}': parameters: - schema: @@ -84,7 +136,7 @@ paths: type: object properties: item: - $ref: '#/components/schemas/agent_policy' + $ref: '#/paths/~1agent_policies/post/responses/200/content/application~1json/schema/properties/item' required: - item operationId: agent-policy-info @@ -102,7 +154,7 @@ paths: type: object properties: item: - $ref: '#/components/schemas/agent_policy' + $ref: '#/paths/~1agent_policies/post/responses/200/content/application~1json/schema/properties/item' required: - item operationId: put-agent-policy-agentPolicyId @@ -110,9 +162,9 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/new_agent_policy' + $ref: '#/paths/~1agent_policies/post/requestBody/content/application~1json/schema' parameters: - - $ref: '#/components/parameters/kbn_xsrf' + - $ref: '#/paths/~1setup/post/parameters/0' '/agent_policies/{agentPolicyId}/copy': parameters: - schema: @@ -132,7 +184,7 @@ paths: type: object properties: item: - $ref: '#/components/schemas/agent_policy' + $ref: '#/paths/~1agent_policies/post/responses/200/content/application~1json/schema/properties/item' required: - item requestBody: @@ -181,7 +233,7 @@ paths: items: type: string parameters: - - $ref: '#/components/parameters/kbn_xsrf' + - $ref: '#/paths/~1setup/post/parameters/0' parameters: [] /agent-status: get: @@ -251,7 +303,7 @@ paths: - action operationId: post-fleet-agents-agentId-acks parameters: - - $ref: '#/components/parameters/kbn_xsrf' + - $ref: '#/paths/~1setup/post/parameters/0' requestBody: content: application/json: @@ -304,7 +356,7 @@ paths: - type operationId: post-fleet-agents-agentId-checkin parameters: - - $ref: '#/components/parameters/kbn_xsrf' + - $ref: '#/paths/~1setup/post/parameters/0' security: - Access API Key: [] requestBody: @@ -314,11 +366,55 @@ paths: type: object properties: local_metadata: - $ref: '#/components/schemas/agent_metadata' + title: AgentMetadata + type: object events: type: array items: - $ref: '#/components/schemas/new_agent_event' + title: NewAgentEvent + type: object + properties: + type: + type: string + enum: + - STATE + - ERROR + - ACTION_RESULT + - ACTION + subtype: + type: string + enum: + - RUNNING + - STARTING + - IN_PROGRESS + - CONFIG + - FAILED + - STOPPING + - STOPPED + - DEGRADED + - DATA_DUMP + - ACKNOWLEDGED + - UNKNOWN + timestamp: + type: string + message: + type: string + payload: + type: string + agent_id: + type: string + policy_id: + type: string + stream_id: + type: string + action_id: + type: string + required: + - type + - subtype + - timestamp + - message + - agent_id '/agents/{agentId}/events': parameters: - schema: @@ -344,7 +440,7 @@ paths: responses: {} operationId: post-fleet-agents-unenroll parameters: - - $ref: '#/components/parameters/kbn_xsrf' + - $ref: '#/paths/~1setup/post/parameters/0' requestBody: content: application/json: @@ -369,22 +465,37 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/upgrade_agent' + $ref: '#/paths/~1agents~1%7BagentId%7D~1upgrade/post/requestBody/content/application~1json/schema' '400': description: BAD REQUEST content: application/json: schema: - $ref: '#/components/schemas/upgrade_agent' + $ref: '#/paths/~1agents~1%7BagentId%7D~1upgrade/post/requestBody/content/application~1json/schema' operationId: post-fleet-agents-upgrade parameters: - - $ref: '#/components/parameters/kbn_xsrf' + - $ref: '#/paths/~1setup/post/parameters/0' requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/upgrade_agent' + title: UpgradeAgent + oneOf: + - type: object + properties: + version: + type: string + required: + - version + - type: object + properties: + version: + type: string + source_uri: + type: string + required: + - version /agents/bulk_upgrade: post: summary: Fleet - Agent - Bulk Upgrade @@ -395,22 +506,58 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/bulk_upgrade_agents' + $ref: '#/paths/~1agents~1bulk_upgrade/post/requestBody/content/application~1json/schema' '400': description: BAD REQUEST content: application/json: schema: - $ref: '#/components/schemas/upgrade_agent' + $ref: '#/paths/~1agents~1%7BagentId%7D~1upgrade/post/requestBody/content/application~1json/schema' operationId: post-fleet-agents-bulk-upgrade parameters: - - $ref: '#/components/parameters/kbn_xsrf' + - $ref: '#/paths/~1setup/post/parameters/0' requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/bulk_upgrade_agents' + title: BulkUpgradeAgents + oneOf: + - type: object + properties: + version: + type: string + agents: + type: array + items: + type: string + required: + - version + - agents + - type: object + properties: + version: + type: string + source_uri: + type: string + agents: + type: array + items: + type: string + required: + - version + - agents + - type: object + properties: + version: + type: string + source_uri: + type: string + agents: + type: string + required: + - version + - agents /agents/enroll: post: summary: Fleet - Agent - Enroll @@ -426,10 +573,78 @@ paths: action: type: string item: - $ref: '#/components/schemas/agent' + title: Agent + type: object + properties: + type: + type: string + title: AgentType + enum: + - PERMANENT + - EPHEMERAL + - TEMPORARY + active: + type: boolean + enrolled_at: + type: string + unenrolled_at: + type: string + unenrollment_started_at: + type: string + shared_id: + type: string + deprecated: true + access_api_key_id: + type: string + default_api_key_id: + type: string + policy_id: + type: string + policy_revision: + type: number + last_checkin: + type: string + user_provided_metadata: + $ref: '#/paths/~1agents~1%7BagentId%7D~1checkin/post/requestBody/content/application~1json/schema/properties/local_metadata' + local_metadata: + $ref: '#/paths/~1agents~1%7BagentId%7D~1checkin/post/requestBody/content/application~1json/schema/properties/local_metadata' + id: + type: string + current_error_events: + type: array + items: + title: AgentEvent + allOf: + - type: object + properties: + id: + type: string + required: + - id + - $ref: '#/paths/~1agents~1%7BagentId%7D~1checkin/post/requestBody/content/application~1json/schema/properties/events/items' + access_api_key: + type: string + status: + type: string + title: AgentStatus + enum: + - offline + - error + - online + - inactive + - warning + default_api_key: + type: string + required: + - type + - active + - enrolled_at + - id + - current_error_events + - status operationId: post-fleet-agents-enroll parameters: - - $ref: '#/components/parameters/kbn_xsrf' + - $ref: '#/paths/~1setup/post/parameters/0' requestBody: content: application/json: @@ -444,6 +659,7 @@ paths: - TEMPORARY shared_id: type: string + deprecated: true metadata: type: object required: @@ -451,9 +667,9 @@ paths: - user_provided properties: local: - $ref: '#/components/schemas/agent_metadata' + $ref: '#/paths/~1agents~1%7BagentId%7D~1checkin/post/requestBody/content/application~1json/schema/properties/local_metadata' user_provided: - $ref: '#/components/schemas/agent_metadata' + $ref: '#/paths/~1agents~1%7BagentId%7D~1checkin/post/requestBody/content/application~1json/schema/properties/local_metadata' required: - type - metadata @@ -507,7 +723,7 @@ paths: - admin_username - admin_password parameters: - - $ref: '#/components/parameters/kbn_xsrf' + - $ref: '#/paths/~1setup/post/parameters/0' /enrollment-api-keys: get: summary: Enrollment - List @@ -521,7 +737,7 @@ paths: responses: {} operationId: post-fleet-enrollment-api-keys parameters: - - $ref: '#/components/parameters/kbn_xsrf' + - $ref: '#/paths/~1setup/post/parameters/0' '/enrollment-api-keys/{keyId}': parameters: - schema: @@ -540,7 +756,7 @@ paths: responses: {} operationId: delete-fleet-enrollment-api-keys-keyId parameters: - - $ref: '#/components/parameters/kbn_xsrf' + - $ref: '#/paths/~1setup/post/parameters/0' /epm/categories: get: summary: EPM - Categories @@ -578,7 +794,39 @@ paths: schema: type: array items: - $ref: '#/components/schemas/search_result' + title: SearchResult + type: object + properties: + description: + type: string + download: + type: string + icons: + type: string + name: + type: string + path: + type: string + title: + type: string + type: + type: string + version: + type: string + status: + type: string + savedObject: + type: object + required: + - description + - download + - icons + - name + - path + - title + - type + - version + - status operationId: get-epm-list parameters: [] '/epm/packages/{pkgkey}': @@ -595,7 +843,124 @@ paths: allOf: - properties: response: - $ref: '#/components/schemas/package_info' + title: PackageInfo + type: object + properties: + name: + type: string + title: + type: string + version: + type: string + readme: + type: string + description: + type: string + type: + type: string + categories: + type: array + items: + type: string + requirement: + oneOf: + - properties: + kibana: + type: object + properties: + versions: + type: string + - properties: + elasticsearch: + type: object + properties: + versions: + type: string + type: object + screenshots: + type: array + items: + type: object + properties: + src: + type: string + path: + type: string + title: + type: string + size: + type: string + type: + type: string + required: + - src + - path + icons: + type: array + items: + type: string + assets: + type: array + items: + type: string + internal: + type: boolean + format_version: + type: string + data_streams: + type: array + items: + type: object + properties: + title: + type: string + name: + type: string + release: + type: string + ingeset_pipeline: + type: string + vars: + type: array + items: + type: object + properties: + name: + type: string + default: + type: string + required: + - name + - default + type: + type: string + package: + type: string + required: + - title + - name + - release + - ingeset_pipeline + - type + - package + download: + type: string + path: + type: string + removable: + type: boolean + required: + - name + - title + - version + - description + - type + - categories + - requirement + - assets + - format_version + - download + - path - properties: status: type: string @@ -644,7 +1009,7 @@ paths: operationId: post-epm-install-pkgkey description: '' parameters: - - $ref: '#/components/parameters/kbn_xsrf' + - $ref: '#/paths/~1setup/post/parameters/0' delete: summary: EPM - Packages - Delete tags: [] @@ -672,7 +1037,7 @@ paths: - response operationId: post-epm-delete-pkgkey parameters: - - $ref: '#/components/parameters/kbn_xsrf' + - $ref: '#/paths/~1setup/post/parameters/0' '/agents/{agentId}': parameters: - schema: @@ -702,14 +1067,14 @@ paths: responses: {} operationId: put-fleet-agents-agentId parameters: - - $ref: '#/components/parameters/kbn_xsrf' + - $ref: '#/paths/~1setup/post/parameters/0' delete: summary: Fleet - Agent - Delete tags: [] responses: {} operationId: delete-fleet-agents-agentId parameters: - - $ref: '#/components/parameters/kbn_xsrf' + - $ref: '#/paths/~1setup/post/parameters/0' '/install/{osType}': parameters: - schema: @@ -737,7 +1102,7 @@ paths: items: type: array items: - $ref: '#/components/schemas/package_policy' + $ref: '#/paths/~1package_policies~1%7BpackagePolicyId%7D/get/responses/200/content/application~1json/schema/properties/item' total: type: number page: @@ -760,9 +1125,66 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/new_package_policy' + title: NewPackagePolicy + type: object + description: '' + properties: + enabled: + type: boolean + package: + type: object + properties: + name: + type: string + version: + type: string + title: + type: string + required: + - name + - version + - title + namespace: + type: string + output_id: + type: string + inputs: + type: array + items: + type: object + properties: + type: + type: string + enabled: + type: boolean + processors: + type: array + items: + type: string + streams: + type: array + items: {} + config: + type: object + vars: + type: object + required: + - type + - enabled + - streams + policy_id: + type: string + name: + type: string + description: + type: string + required: + - output_id + - inputs + - policy_id + - name parameters: - - $ref: '#/components/parameters/kbn_xsrf' + - $ref: '#/paths/~1setup/post/parameters/0' '/package_policies/{packagePolicyId}': get: summary: PackagePolicies - Info @@ -776,7 +1198,21 @@ paths: type: object properties: item: - $ref: '#/components/schemas/package_policy' + title: PackagePolicy + allOf: + - type: object + properties: + id: + type: string + revision: + type: number + inputs: + type: array + items: {} + required: + - id + - revision + - $ref: '#/paths/~1package_policies/post/requestBody/content/application~1json/schema' required: - item operationId: get-packagePolicies-packagePolicyId @@ -793,7 +1229,13 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/update_package_policy' + title: UpdatePackagePolicy + allOf: + - type: object + properties: + version: + type: string + - $ref: '#/paths/~1package_policies/post/requestBody/content/application~1json/schema' responses: '200': description: OK @@ -803,14 +1245,14 @@ paths: type: object properties: item: - $ref: '#/components/schemas/package_policy' + $ref: '#/paths/~1package_policies~1%7BpackagePolicyId%7D/get/responses/200/content/application~1json/schema/properties/item' sucess: type: boolean required: - item - sucess parameters: - - $ref: '#/components/parameters/kbn_xsrf' + - $ref: '#/paths/~1setup/post/parameters/0' /setup: post: summary: Ingest Manager - Setup @@ -836,7 +1278,11 @@ paths: type: string operationId: post-setup parameters: - - $ref: '#/components/parameters/kbn_xsrf' + - schema: + type: string + in: header + name: kbn-xsrf + required: true components: securitySchemes: basicAuth: @@ -852,489 +1298,5 @@ components: type: apiKey in: header description: 'e.g. Authorization: ApiKey base64AccessApiKey' - parameters: - page_size: - name: perPage - in: query - description: The number of items to return - required: false - schema: - type: integer - default: 50 - page_index: - name: page - in: query - required: false - schema: - type: integer - default: 1 - kuery: - name: kuery - in: query - required: false - schema: - type: string - kbn_xsrf: - schema: - type: string - in: header - name: kbn-xsrf - required: true - schemas: - new_agent_policy: - title: NewAgentPolicy - type: object - properties: - name: - type: string - namespace: - type: string - description: - type: string - new_package_policy: - title: NewPackagePolicy - type: object - description: '' - properties: - enabled: - type: boolean - package: - type: object - properties: - name: - type: string - version: - type: string - title: - type: string - required: - - name - - version - - title - namespace: - type: string - output_id: - type: string - inputs: - type: array - items: - type: object - properties: - type: - type: string - enabled: - type: boolean - processors: - type: array - items: - type: string - streams: - type: array - items: {} - config: - type: object - vars: - type: object - required: - - type - - enabled - - streams - policy_id: - type: string - name: - type: string - description: - type: string - required: - - output_id - - inputs - - policy_id - - name - package_policy: - title: PackagePolicy - allOf: - - type: object - properties: - id: - type: string - revision: - type: number - inputs: - type: array - items: {} - required: - - id - - revision - - $ref: '#/components/schemas/new_package_policy' - agent_policy: - allOf: - - $ref: '#/components/schemas/new_agent_policy' - - type: object - properties: - id: - type: string - status: - type: string - enum: - - active - - inactive - packagePolicies: - oneOf: - - items: - type: string - - items: - $ref: '#/components/schemas/package_policy' - type: array - updated_on: - type: string - format: date-time - updated_by: - type: string - revision: - type: number - agents: - type: number - required: - - id - - status - agent_metadata: - title: AgentMetadata - type: object - new_agent_event: - title: NewAgentEvent - type: object - properties: - type: - type: string - enum: - - STATE - - ERROR - - ACTION_RESULT - - ACTION - subtype: - type: string - enum: - - RUNNING - - STARTING - - IN_PROGRESS - - CONFIG - - FAILED - - STOPPING - - STOPPED - - DEGRADED - - DATA_DUMP - - ACKNOWLEDGED - - UNKNOWN - timestamp: - type: string - message: - type: string - payload: - type: string - agent_id: - type: string - policy_id: - type: string - stream_id: - type: string - action_id: - type: string - required: - - type - - subtype - - timestamp - - message - - agent_id - upgrade_agent: - title: UpgradeAgent - oneOf: - - type: object - properties: - version: - type: string - required: - - version - - type: object - properties: - version: - type: string - source_uri: - type: string - required: - - version - bulk_upgrade_agents: - title: BulkUpgradeAgents - oneOf: - - type: object - properties: - version: - type: string - agents: - type: array - items: - type: string - required: - - version - - agents - - type: object - properties: - version: - type: string - source_uri: - type: string - agents: - type: array - items: - type: string - required: - - version - - agents - - type: object - properties: - version: - type: string - source_uri: - type: string - agents: - type: string - required: - - version - - agents - agent_type: - type: string - title: AgentType - enum: - - PERMANENT - - EPHEMERAL - - TEMPORARY - agent_event: - title: AgentEvent - allOf: - - type: object - properties: - id: - type: string - required: - - id - - $ref: '#/components/schemas/new_agent_event' - agent_status: - type: string - title: AgentStatus - enum: - - offline - - error - - online - - inactive - - warning - agent: - title: Agent - type: object - properties: - type: - $ref: '#/components/schemas/agent_type' - active: - type: boolean - enrolled_at: - type: string - unenrolled_at: - type: string - unenrollment_started_at: - type: string - shared_id: - type: string - access_api_key_id: - type: string - default_api_key_id: - type: string - policy_id: - type: string - policy_revision: - type: number - last_checkin: - type: string - user_provided_metadata: - $ref: '#/components/schemas/agent_metadata' - local_metadata: - $ref: '#/components/schemas/agent_metadata' - id: - type: string - current_error_events: - type: array - items: - $ref: '#/components/schemas/agent_event' - access_api_key: - type: string - status: - $ref: '#/components/schemas/agent_status' - default_api_key: - type: string - required: - - type - - active - - enrolled_at - - id - - current_error_events - - status - search_result: - title: SearchResult - type: object - properties: - description: - type: string - download: - type: string - icons: - type: string - name: - type: string - path: - type: string - title: - type: string - type: - type: string - version: - type: string - status: - type: string - savedObject: - type: object - required: - - description - - download - - icons - - name - - path - - title - - type - - version - - status - package_info: - title: PackageInfo - type: object - properties: - name: - type: string - title: - type: string - version: - type: string - readme: - type: string - description: - type: string - type: - type: string - categories: - type: array - items: - type: string - requirement: - oneOf: - - properties: - kibana: - type: object - properties: - versions: - type: string - - properties: - elasticsearch: - type: object - properties: - versions: - type: string - type: object - screenshots: - type: array - items: - type: object - properties: - src: - type: string - path: - type: string - title: - type: string - size: - type: string - type: - type: string - required: - - src - - path - icons: - type: array - items: - type: string - assets: - type: array - items: - type: string - internal: - type: boolean - format_version: - type: string - data_streams: - type: array - items: - type: object - properties: - title: - type: string - name: - type: string - release: - type: string - ingeset_pipeline: - type: string - vars: - type: array - items: - type: object - properties: - name: - type: string - default: - type: string - required: - - name - - default - type: - type: string - package: - type: string - required: - - title - - name - - release - - ingeset_pipeline - - type - - package - download: - type: string - path: - type: string - removable: - type: boolean - required: - - name - - title - - version - - description - - type - - categories - - requirement - - assets - - format_version - - download - - path - update_package_policy: - title: UpdatePackagePolicy - allOf: - - type: object - properties: - version: - type: string - - $ref: '#/components/schemas/new_package_policy' security: - basicAuth: [] diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/agent.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/agent.yaml index df106093a8d8d7..a2647b71c70cc2 100644 --- a/x-pack/plugins/fleet/common/openapi/components/schemas/agent.yaml +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/agent.yaml @@ -13,6 +13,7 @@ properties: type: string shared_id: type: string + deprecated: true access_api_key_id: type: string default_api_key_id: diff --git a/x-pack/plugins/fleet/common/openapi/paths/agents@enroll.yaml b/x-pack/plugins/fleet/common/openapi/paths/agents@enroll.yaml index a0c1c8c28e7211..1946a65e33fdc9 100644 --- a/x-pack/plugins/fleet/common/openapi/paths/agents@enroll.yaml +++ b/x-pack/plugins/fleet/common/openapi/paths/agents@enroll.yaml @@ -30,6 +30,7 @@ post: - TEMPORARY shared_id: type: string + deprecated: true metadata: type: object required: diff --git a/x-pack/plugins/fleet/common/types/models/agent.ts b/x-pack/plugins/fleet/common/types/models/agent.ts index 59fab14f90e6ea..b59249da2dd34a 100644 --- a/x-pack/plugins/fleet/common/types/models/agent.ts +++ b/x-pack/plugins/fleet/common/types/models/agent.ts @@ -130,7 +130,6 @@ interface AgentBase { unenrollment_started_at?: string; upgraded_at?: string; upgrade_started_at?: string; - shared_id?: string; access_api_key_id?: string; default_api_key?: string; default_api_key_id?: string; diff --git a/x-pack/plugins/fleet/common/types/rest_spec/agent.ts b/x-pack/plugins/fleet/common/types/rest_spec/agent.ts index f758ca0921a081..925ed4b8b16380 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/agent.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/agent.ts @@ -62,7 +62,6 @@ export interface PostAgentCheckinResponse { export interface PostAgentEnrollRequest { body: { type: AgentType; - shared_id?: string; metadata: { local: Record; user_provided: Record; diff --git a/x-pack/plugins/fleet/dev_docs/api/agents_enroll.md b/x-pack/plugins/fleet/dev_docs/api/agents_enroll.md index 977b3029371ba0..7dd56338b31fa4 100644 --- a/x-pack/plugins/fleet/dev_docs/api/agents_enroll.md +++ b/x-pack/plugins/fleet/dev_docs/api/agents_enroll.md @@ -13,7 +13,6 @@ Enroll agent ## Request body - `type` (Required, string) Agent type should be one of `EPHEMERAL`, `TEMPORARY`, `PERMANENT` -- `shared_id` (Optional, string) An ID for the agent. - `metadata` (Optional, object) Objects with `local` and `user_provided` properties that contain the metadata for an agent. The metadata is a dictionary of strings (example: `"local": { "os": "macos" }`). ## Response code @@ -68,12 +67,3 @@ The API will return a response with a `401` status code and an error if the enro } ``` -The API will return a response with a `400` status code and an error if you enroll an agent with the same `shared_id` than an already active agent: - -```js -{ - "statusCode": 400, - "error": "BadRequest", - "message": "Impossible to enroll an already active agent" -} -``` diff --git a/x-pack/plugins/fleet/server/routes/agent/handlers.ts b/x-pack/plugins/fleet/server/routes/agent/handlers.ts index 0cd53a2313d2a8..ace18e10115d1c 100644 --- a/x-pack/plugins/fleet/server/routes/agent/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/agent/handlers.ts @@ -220,8 +220,7 @@ export const postAgentEnrollHandler: RequestHandler< { userProvided: request.body.metadata.user_provided, local: request.body.metadata.local, - }, - request.body.shared_id + } ); const body: PostAgentEnrollResponse = { action: 'created', diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts index 20bbee2b1c791d..dcc686e565b8e9 100644 --- a/x-pack/plugins/fleet/server/saved_objects/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/index.ts @@ -28,6 +28,7 @@ import { migrateSettingsToV7100, migrateAgentActionToV7100, } from './migrations/to_v7_10_0'; +import { migrateAgentToV7120 } from './migrations/to_v7_12_0'; /* * Saved object types and mappings @@ -67,7 +68,6 @@ const getSavedObjectTypes = ( }, mappings: { properties: { - shared_id: { type: 'keyword' }, type: { type: 'keyword' }, active: { type: 'boolean' }, enrolled_at: { type: 'date' }, @@ -93,6 +93,7 @@ const getSavedObjectTypes = ( }, migrations: { '7.10.0': migrateAgentToV7100, + '7.12.0': migrateAgentToV7120, }, }, [AGENT_ACTION_SAVED_OBJECT_TYPE]: { @@ -385,7 +386,6 @@ export function registerEncryptedSavedObjects( type: AGENT_SAVED_OBJECT_TYPE, attributesToEncrypt: new Set(['default_api_key']), attributesToExcludeFromAAD: new Set([ - 'shared_id', 'type', 'active', 'enrolled_at', diff --git a/x-pack/plugins/fleet/server/saved_objects/migrations/to_v7_12_0.ts b/x-pack/plugins/fleet/server/saved_objects/migrations/to_v7_12_0.ts new file mode 100644 index 00000000000000..841e56a60091b1 --- /dev/null +++ b/x-pack/plugins/fleet/server/saved_objects/migrations/to_v7_12_0.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectMigrationFn } from 'kibana/server'; +import { Agent } from '../../types'; + +export const migrateAgentToV7120: SavedObjectMigrationFn = ( + agentDoc +) => { + delete agentDoc.attributes.shared_id; + + return agentDoc; +}; diff --git a/x-pack/plugins/fleet/server/services/agents/enroll.ts b/x-pack/plugins/fleet/server/services/agents/enroll.ts index 39b757b9776ed3..113f302d52b45e 100644 --- a/x-pack/plugins/fleet/server/services/agents/enroll.ts +++ b/x-pack/plugins/fleet/server/services/agents/enroll.ts @@ -20,26 +20,16 @@ export async function enroll( soClient: SavedObjectsClientContract, type: AgentType, agentPolicyId: string, - metadata?: { local: any; userProvided: any }, - sharedId?: string + metadata?: { local: any; userProvided: any } ): Promise { const agentVersion = metadata?.local?.elastic?.agent?.version; validateAgentVersion(agentVersion); - const existingAgent = sharedId ? await getAgentBySharedId(soClient, sharedId) : null; - - if (existingAgent && existingAgent.active === true) { - throw Boom.badRequest('Impossible to enroll an already active agent'); - } - - const enrolledAt = new Date().toISOString(); - const agentData: AgentSOAttributes = { - shared_id: sharedId, active: true, policy_id: agentPolicyId, type, - enrolled_at: enrolledAt, + enrolled_at: new Date().toISOString(), user_provided_metadata: metadata?.userProvided ?? {}, local_metadata: metadata?.local ?? {}, current_error_events: undefined, @@ -48,25 +38,11 @@ export async function enroll( default_api_key: undefined, }; - let agent; - if (existingAgent) { - await soClient.update(AGENT_SAVED_OBJECT_TYPE, existingAgent.id, agentData, { + const agent = savedObjectToAgent( + await soClient.create(AGENT_SAVED_OBJECT_TYPE, agentData, { refresh: false, - }); - agent = { - ...existingAgent, - ...agentData, - user_provided_metadata: metadata?.userProvided ?? {}, - local_metadata: metadata?.local ?? {}, - current_error_events: [], - } as Agent; - } else { - agent = savedObjectToAgent( - await soClient.create(AGENT_SAVED_OBJECT_TYPE, agentData, { - refresh: false, - }) - ); - } + }) + ); const accessAPIKey = await APIKeyService.generateAccessApiKey(soClient, agent.id); @@ -77,22 +53,6 @@ export async function enroll( return { ...agent, access_api_key: accessAPIKey.key }; } -async function getAgentBySharedId(soClient: SavedObjectsClientContract, sharedId: string) { - const response = await soClient.find({ - type: AGENT_SAVED_OBJECT_TYPE, - searchFields: ['shared_id'], - search: sharedId, - }); - - const agents = response.saved_objects.map(savedObjectToAgent); - - if (agents.length > 0) { - return agents[0]; - } - - return null; -} - export function validateAgentVersion( agentVersion: string, kibanaVersion = appContextService.getKibanaVersion() diff --git a/x-pack/plugins/fleet/server/types/rest_spec/agent.ts b/x-pack/plugins/fleet/server/types/rest_spec/agent.ts index 3e9262c2a91243..a37002114c7719 100644 --- a/x-pack/plugins/fleet/server/types/rest_spec/agent.ts +++ b/x-pack/plugins/fleet/server/types/rest_spec/agent.ts @@ -83,6 +83,7 @@ export const PostAgentEnrollRequestBodyJSONSchema = { type: 'object', properties: { type: { type: 'string', enum: ['EPHEMERAL', 'PERMANENT', 'TEMPORARY'] }, + // TODO deprecated should be removed in 8.0.0 shared_id: { type: 'string' }, metadata: { type: 'object', diff --git a/x-pack/test/fleet_api_integration/apis/agents/enroll.ts b/x-pack/test/fleet_api_integration/apis/agents/enroll.ts index c88106eb79cd25..609b28417914ed 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/enroll.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/enroll.ts @@ -74,28 +74,6 @@ export default function (providerContext: FtrProviderContext) { .expect(401); }); - it('should not allow to enroll an agent with a shared id if it already exists ', async () => { - const { body: apiResponse } = await supertest - .post(`/api/fleet/agents/enroll`) - .set('kbn-xsrf', 'xxx') - .set( - 'authorization', - `ApiKey ${Buffer.from(`${apiKey.id}:${apiKey.api_key}`).toString('base64')}` - ) - .send({ - shared_id: 'agent2_filebeat', - type: 'PERMANENT', - metadata: { - local: { - elastic: { agent: { version: kibanaVersion } }, - }, - user_provided: {}, - }, - }) - .expect(400); - expect(apiResponse.message).to.match(/Impossible to enroll an already active agent/); - }); - it('should not allow to enroll an agent with a version > kibana', async () => { const { body: apiResponse } = await supertest .post(`/api/fleet/agents/enroll`) diff --git a/x-pack/test/fleet_api_integration/apis/agents/list.ts b/x-pack/test/fleet_api_integration/apis/agents/list.ts index 78a6dbb7d651a1..1b3d3e7d32cb7d 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/list.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/list.ts @@ -104,14 +104,14 @@ export default function ({ getService }: FtrProviderContext) { .expect(400); }); it('should accept a valid "kuery" value', async () => { - const filter = encodeURIComponent('fleet-agents.shared_id : "agent2_filebeat"'); + const filter = encodeURIComponent('fleet-agents.access_api_key_id : "api-key-2"'); const { body: apiResponse } = await supertest .get(`/api/fleet/agents?kuery=${filter}`) .expect(200); expect(apiResponse.total).to.eql(1); const agent = apiResponse.list[0]; - expect(agent.shared_id).to.eql('agent2_filebeat'); + expect(agent.access_api_key_id).to.eql('api-key-2'); }); }); } diff --git a/x-pack/test/functional/es_archives/fleet/agents/data.json b/x-pack/test/functional/es_archives/fleet/agents/data.json index f204e44b31bc9c..ca957e5ae2fedf 100644 --- a/x-pack/test/functional/es_archives/fleet/agents/data.json +++ b/x-pack/test/functional/es_archives/fleet/agents/data.json @@ -6,9 +6,8 @@ "source": { "type": "fleet-agents", "fleet-agents": { - "access_api_key_id": "api-key-2", + "access_api_key_id": "api-key-1", "active": true, - "shared_id": "agent1_filebeat", "policy_id": "policy1", "type": "PERMANENT", "local_metadata": {}, @@ -31,7 +30,6 @@ "fleet-agents": { "access_api_key_id": "api-key-2", "active": true, - "shared_id": "agent2_filebeat", "policy_id": "policy1", "type": "PERMANENT", "local_metadata": {}, @@ -54,7 +52,6 @@ "fleet-agents": { "access_api_key_id": "api-key-3", "active": true, - "shared_id": "agent3_metricbeat", "policy_id": "policy1", "type": "PERMANENT", "local_metadata": {}, @@ -77,7 +74,6 @@ "fleet-agents": { "access_api_key_id": "api-key-4", "active": true, - "shared_id": "agent4_metricbeat", "policy_id": "policy1", "type": "PERMANENT", "local_metadata": {}, From 440238b051b7d2b878ff2cfb23b6afa52afd05cb Mon Sep 17 00:00:00 2001 From: Constance Date: Thu, 21 Jan 2021 10:55:48 -0800 Subject: [PATCH 06/16] [App Search] Add generatePath helper for generating engine links (#88782) * Add a generatePath engineName helper to EngineLogic * Create mockEngineValues reusable mock * Update routes + EngineNav & EngineRouter to include ENGINE_PATH in all urls - routes: remove get*Route fns in here as all routes should prefer to use generatePath from EngineLogic moving forward - EngineRouter - add missing canViewEngineDocuments checks - Engine tests - import base mock values + update tests to point directly at files to work around the auto mock * Update AnalyticsRouter to use new routes+generatePath * Update DocumentDetailLogic to use new generatePath + Misc cleanup: - organize imports by shared > AS specific > docs specific - move delete-specific const's to directly before they're used, since they're only used in one place - deconstruct KibanaLogic.values * Update all components using getEngineRoute to use new generatePath + misc import order cleanup - prefer shared > specific groupings * [PR feedback] Change components that override the engineName param to just use default generatePath * [PR feedback] Rename instances of EngineLogic's generatePath to generateEnginePath --- .../app_search/__mocks__/engine_logic.mock.ts | 23 ++++++++++ .../app_search/__mocks__/index.ts | 7 +++ .../analytics/analytics_router.test.tsx | 4 ++ .../components/analytics/analytics_router.tsx | 27 +++++------ .../document_creation_buttons.test.tsx | 8 ++-- .../document_creation_buttons.tsx | 6 +-- .../documents/document_detail_logic.test.ts | 6 +-- .../documents/document_detail_logic.ts | 45 ++++++++++--------- .../components/engine/engine_logic.test.ts | 23 ++++++++++ .../components/engine/engine_logic.ts | 11 +++++ .../components/engine/engine_nav.test.tsx | 5 ++- .../components/engine/engine_nav.tsx | 28 ++++++------ .../components/engine/engine_router.test.tsx | 5 ++- .../components/engine/engine_router.tsx | 7 ++- .../components/recent_api_logs.test.tsx | 5 +-- .../components/recent_api_logs.tsx | 10 ++--- .../components/total_charts.test.tsx | 3 +- .../components/total_charts.tsx | 14 +++--- .../components/engines/engines_table.test.tsx | 3 +- .../components/engines/engines_table.tsx | 7 +-- .../app_search/components/result/result.tsx | 13 ++++-- .../public/applications/app_search/routes.ts | 34 ++++++-------- 22 files changed, 177 insertions(+), 117 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/__mocks__/engine_logic.mock.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/__mocks__/index.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/__mocks__/engine_logic.mock.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/__mocks__/engine_logic.mock.ts new file mode 100644 index 00000000000000..5c327f64d77756 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/__mocks__/engine_logic.mock.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { generatePath } from 'react-router-dom'; + +export const mockEngineValues = { + engineName: 'some-engine', + // Note: using getters allows us to use `this`, which lets tests + // override engineName and still generate correct engine names + get generateEnginePath() { + return jest.fn((path, pathParams = {}) => + generatePath(path, { engineName: this.engineName, ...pathParams }) + ); + }, + engine: {}, +}; + +jest.mock('../components/engine', () => ({ + EngineLogic: { values: mockEngineValues }, +})); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/__mocks__/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/__mocks__/index.ts new file mode 100644 index 00000000000000..0b0a85b6fca924 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/__mocks__/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { mockEngineValues } from './engine_logic.mock'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.test.tsx index 82d2a6614a32a2..aea107a137da1e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.test.tsx @@ -4,6 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +import { setMockValues } from '../../../__mocks__'; +import { mockEngineValues } from '../../__mocks__'; + import React from 'react'; import { shallow } from 'enzyme'; import { Route, Switch } from 'react-router-dom'; @@ -13,6 +16,7 @@ import { AnalyticsRouter } from './'; describe('AnalyticsRouter', () => { // Detailed route testing is better done via E2E tests it('renders', () => { + setMockValues(mockEngineValues); const wrapper = shallow(); expect(wrapper.find(Switch)).toHaveLength(1); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.tsx index ac5c472a9a388f..60c0f2a3fd3e87 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.tsx @@ -6,14 +6,13 @@ import React from 'react'; import { Route, Switch, Redirect } from 'react-router-dom'; +import { useValues } from 'kea'; import { APP_SEARCH_PLUGIN } from '../../../../../common/constants'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { BreadcrumbTrail } from '../../../shared/kibana_chrome/generate_breadcrumbs'; import { NotFound } from '../../../shared/not_found'; import { - getEngineRoute, - ENGINE_PATH, ENGINE_ANALYTICS_PATH, ENGINE_ANALYTICS_TOP_QUERIES_PATH, ENGINE_ANALYTICS_TOP_QUERIES_NO_RESULTS_PATH, @@ -23,6 +22,8 @@ import { ENGINE_ANALYTICS_QUERY_DETAILS_PATH, ENGINE_ANALYTICS_QUERY_DETAIL_PATH, } from '../../routes'; +import { EngineLogic } from '../engine'; + import { ANALYTICS_TITLE, TOP_QUERIES, @@ -31,7 +32,6 @@ import { TOP_QUERIES_WITH_CLICKS, RECENT_QUERIES, } from './constants'; - import { Analytics, TopQueries, @@ -46,40 +46,41 @@ interface Props { engineBreadcrumb: BreadcrumbTrail; } export const AnalyticsRouter: React.FC = ({ engineBreadcrumb }) => { + const { generateEnginePath } = useValues(EngineLogic); + const ANALYTICS_BREADCRUMB = [...engineBreadcrumb, ANALYTICS_TITLE]; - const engineName = engineBreadcrumb[1]; return ( - + - + - + - + - + - + - + - - + + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.test.tsx index 93aff04b3f7c07..d8684355c1a81b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.test.tsx @@ -5,6 +5,7 @@ */ import { setMockValues, setMockActions } from '../../../__mocks__/kea.mock'; +import { mockEngineValues } from '../../__mocks__'; import React from 'react'; import { shallow } from 'enzyme'; @@ -14,16 +15,13 @@ import { EuiCardTo } from '../../../shared/react_router_helpers'; import { DocumentCreationButtons } from './'; describe('DocumentCreationButtons', () => { - const values = { - engineName: 'test-engine', - }; const actions = { openDocumentCreation: jest.fn(), }; beforeEach(() => { jest.clearAllMocks(); - setMockValues(values); + setMockValues(mockEngineValues); setMockActions(actions); }); @@ -57,6 +55,6 @@ describe('DocumentCreationButtons', () => { it('renders the crawler button with a link to the crawler page', () => { const wrapper = shallow(); - expect(wrapper.find(EuiCardTo).prop('to')).toEqual('/engines/test-engine/crawler'); + expect(wrapper.find(EuiCardTo).prop('to')).toEqual('/engines/some-engine/crawler'); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.tsx index ce7cae56783382..93c93224b59822 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.tsx @@ -21,7 +21,7 @@ import { } from '@elastic/eui'; import { EuiCardTo } from '../../../shared/react_router_helpers'; -import { DOCS_PREFIX, getEngineRoute, ENGINE_CRAWLER_PATH } from '../../routes'; +import { DOCS_PREFIX, ENGINE_CRAWLER_PATH } from '../../routes'; import { EngineLogic } from '../engine'; import { DocumentCreationLogic } from './'; @@ -33,8 +33,8 @@ interface Props { export const DocumentCreationButtons: React.FC = ({ disabled = false }) => { const { openDocumentCreation } = useActions(DocumentCreationLogic); - const { engineName } = useValues(EngineLogic); - const crawlerLink = getEngineRoute(engineName) + ENGINE_CRAWLER_PATH; + const { generateEnginePath } = useValues(EngineLogic); + const crawlerLink = generateEnginePath(ENGINE_CRAWLER_PATH); return ( <> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.test.ts index f7476083009df0..e33cd9b0e9e71f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.test.ts @@ -11,10 +11,7 @@ import { mockFlashMessageHelpers, expectedAsyncError, } from '../../../__mocks__'; - -jest.mock('../engine', () => ({ - EngineLogic: { values: { engineName: 'engine1' } }, -})); +import { mockEngineValues } from '../../__mocks__'; import { DocumentDetailLogic } from './document_detail_logic'; import { InternalSchemaTypes } from '../../../shared/types'; @@ -32,6 +29,7 @@ describe('DocumentDetailLogic', () => { beforeEach(() => { jest.clearAllMocks(); + mockEngineValues.engineName = 'engine1'; }); describe('actions', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.ts index 62db2bf1723543..b8d67ac56b3a28 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.ts @@ -7,12 +7,14 @@ import { kea, MakeLogicType } from 'kea'; import { i18n } from '@kbn/i18n'; +import { flashAPIErrors, setQueuedSuccessMessage } from '../../../shared/flash_messages'; +import { KibanaLogic } from '../../../shared/kibana'; import { HttpLogic } from '../../../shared/http'; + +import { ENGINE_DOCUMENTS_PATH } from '../../routes'; import { EngineLogic } from '../engine'; -import { flashAPIErrors, setQueuedSuccessMessage } from '../../../shared/flash_messages'; + import { FieldDetails } from './types'; -import { KibanaLogic } from '../../../shared/kibana'; -import { ENGINE_DOCUMENTS_PATH, getEngineRoute } from '../../routes'; interface DocumentDetailLogicValues { dataLoading: boolean; @@ -27,19 +29,6 @@ interface DocumentDetailLogicActions { type DocumentDetailLogicType = MakeLogicType; -const CONFIRM_DELETE = i18n.translate( - 'xpack.enterpriseSearch.appSearch.documentDetail.confirmDelete', - { - defaultMessage: 'Are you sure you want to delete this document?', - } -); -const DELETE_SUCCESS = i18n.translate( - 'xpack.enterpriseSearch.appSearch.documentDetail.deleteSuccess', - { - defaultMessage: 'Successfully marked document for deletion. It will be deleted momentarily.', - } -); - export const DocumentDetailLogic = kea({ path: ['enterprise_search', 'app_search', 'document_detail_logic'], actions: () => ({ @@ -63,7 +52,8 @@ export const DocumentDetailLogic = kea({ }), listeners: ({ actions }) => ({ getDocumentDetails: async ({ documentId }) => { - const { engineName } = EngineLogic.values; + const { engineName, generateEnginePath } = EngineLogic.values; + const { navigateToUrl } = KibanaLogic.values; try { const { http } = HttpLogic.values; @@ -76,20 +66,31 @@ export const DocumentDetailLogic = kea({ // error that will prevent the page from loading, so redirect to the documents page and // show the error flashAPIErrors(e, { isQueued: true }); - const engineRoute = getEngineRoute(engineName); - KibanaLogic.values.navigateToUrl(engineRoute + ENGINE_DOCUMENTS_PATH); + navigateToUrl(generateEnginePath(ENGINE_DOCUMENTS_PATH)); } }, deleteDocument: async ({ documentId }) => { - const { engineName } = EngineLogic.values; + const { engineName, generateEnginePath } = EngineLogic.values; + const { navigateToUrl } = KibanaLogic.values; + + const CONFIRM_DELETE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.documentDetail.confirmDelete', + { defaultMessage: 'Are you sure you want to delete this document?' } + ); + const DELETE_SUCCESS = i18n.translate( + 'xpack.enterpriseSearch.appSearch.documentDetail.deleteSuccess', + { + defaultMessage: + 'Successfully marked document for deletion. It will be deleted momentarily.', + } + ); if (window.confirm(CONFIRM_DELETE)) { try { const { http } = HttpLogic.values; await http.delete(`/api/app_search/engines/${engineName}/documents/${documentId}`); setQueuedSuccessMessage(DELETE_SUCCESS); - const engineRoute = getEngineRoute(engineName); - KibanaLogic.values.navigateToUrl(engineRoute + ENGINE_DOCUMENTS_PATH); + navigateToUrl(generateEnginePath(ENGINE_DOCUMENTS_PATH)); } catch (e) { flashAPIErrors(e); } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts index 48cbaeef70c1ae..32c3382cf187a6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts @@ -36,6 +36,7 @@ describe('EngineLogic', () => { dataLoading: true, engine: {}, engineName: '', + generateEnginePath: expect.any(Function), isMetaEngine: false, isSampleEngine: false, hasSchemaConflicts: false, @@ -197,6 +198,28 @@ describe('EngineLogic', () => { }); describe('selectors', () => { + describe('generateEnginePath', () => { + it('returns helper function that generates paths with engineName prefilled', () => { + mount({ engineName: 'hello-world' }); + + const generatedPath = EngineLogic.values.generateEnginePath('/engines/:engineName/example'); + expect(generatedPath).toEqual('/engines/hello-world/example'); + }); + + it('allows overriding engineName and filling other params', () => { + mount({ engineName: 'lorem-ipsum' }); + + const generatedPath = EngineLogic.values.generateEnginePath( + '/engines/:engineName/foo/:bar', + { + engineName: 'dolor-sit', + bar: 'baz', + } + ); + expect(generatedPath).toEqual('/engines/dolor-sit/foo/baz'); + }); + }); + describe('isSampleEngine', () => { it('should be set based on engine.sample', () => { const mockSampleEngine = { ...mockEngineData, sample: true }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.ts index 9f3fe721b74de2..04d06b596080af 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.ts @@ -5,6 +5,7 @@ */ import { kea, MakeLogicType } from 'kea'; +import { generatePath } from 'react-router-dom'; import { HttpLogic } from '../../../shared/http'; @@ -15,6 +16,7 @@ interface EngineValues { dataLoading: boolean; engine: Partial; engineName: string; + generateEnginePath: Function; isMetaEngine: boolean; isSampleEngine: boolean; hasSchemaConflicts: boolean; @@ -76,6 +78,15 @@ export const EngineLogic = kea>({ ], }, selectors: ({ selectors }) => ({ + generateEnginePath: [ + () => [selectors.engineName], + (engineName) => { + const generateEnginePath = (path: string, pathParams: object = {}) => { + return generatePath(path, { engineName, ...pathParams }); + }; + return generateEnginePath; + }, + ], isMetaEngine: [() => [selectors.engine], (engine) => engine?.type === 'meta'], isSampleEngine: [() => [selectors.engine], (engine) => !!engine?.sample], hasSchemaConflicts: [ diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.test.tsx index 95c9beb9b866ed..f4ef2f5963c329 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.test.tsx @@ -5,15 +5,16 @@ */ import { setMockValues, rerender } from '../../../__mocks__'; +import { mockEngineValues } from '../../__mocks__'; import React from 'react'; import { shallow } from 'enzyme'; import { EuiBadge, EuiIcon } from '@elastic/eui'; -import { EngineNav } from './'; +import { EngineNav } from './engine_nav'; describe('EngineNav', () => { - const values = { myRole: {}, engineName: 'some-engine', dataLoading: false, engine: {} }; + const values = { ...mockEngineValues, myRole: {}, dataLoading: false }; beforeEach(() => { setMockValues(values); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx index 40ae2cef0acb86..fd30e04d349329 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx @@ -13,7 +13,7 @@ import { i18n } from '@kbn/i18n'; import { SideNavLink, SideNavItem } from '../../../shared/layout'; import { AppLogic } from '../../app_logic'; import { - getEngineRoute, + ENGINE_PATH, ENGINE_ANALYTICS_PATH, ENGINE_DOCUMENTS_PATH, ENGINE_SCHEMA_PATH, @@ -64,6 +64,7 @@ export const EngineNav: React.FC = () => { const { engineName, + generateEnginePath, dataLoading, isSampleEngine, isMetaEngine, @@ -75,7 +76,6 @@ export const EngineNav: React.FC = () => { if (dataLoading) return null; if (!engineName) return null; - const engineRoute = getEngineRoute(engineName); const { invalidBoosts, unsearchedUnconfirmedFields } = engine as Required; return ( @@ -99,12 +99,12 @@ export const EngineNav: React.FC = () => { )} - + {OVERVIEW_TITLE} {canViewEngineAnalytics && ( @@ -113,7 +113,7 @@ export const EngineNav: React.FC = () => { )} {canViewEngineDocuments && ( @@ -123,7 +123,7 @@ export const EngineNav: React.FC = () => { {canViewEngineSchema && ( @@ -158,7 +158,7 @@ export const EngineNav: React.FC = () => { {canViewEngineCrawler && !isMetaEngine && ( {CRAWLER_TITLE} @@ -167,7 +167,7 @@ export const EngineNav: React.FC = () => { {canViewMetaEngineSourceEngines && isMetaEngine && ( {ENGINES_TITLE} @@ -176,7 +176,7 @@ export const EngineNav: React.FC = () => { {canManageEngineRelevanceTuning && ( @@ -211,7 +211,7 @@ export const EngineNav: React.FC = () => { {canManageEngineSynonyms && ( {SYNONYMS_TITLE} @@ -220,7 +220,7 @@ export const EngineNav: React.FC = () => { {canManageEngineCurations && ( {CURATIONS_TITLE} @@ -229,7 +229,7 @@ export const EngineNav: React.FC = () => { {canManageEngineResultSettings && ( {RESULT_SETTINGS_TITLE} @@ -238,7 +238,7 @@ export const EngineNav: React.FC = () => { {canManageEngineSearchUi && ( {SEARCH_UI_TITLE} @@ -247,7 +247,7 @@ export const EngineNav: React.FC = () => { {canViewEngineApiLogs && ( {API_LOGS_TITLE} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx index 362454c31f0d96..aa8b406cf7774e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx @@ -7,6 +7,7 @@ import '../../../__mocks__/react_router_history.mock'; import { unmountHandler } from '../../../__mocks__/shallow_useeffect.mock'; import { mockFlashMessageHelpers, setMockValues, setMockActions } from '../../../__mocks__'; +import { mockEngineValues } from '../../__mocks__'; import React from 'react'; import { shallow } from 'enzyme'; @@ -16,14 +17,14 @@ import { Loading } from '../../../shared/loading'; import { EngineOverview } from '../engine_overview'; import { AnalyticsRouter } from '../analytics'; -import { EngineRouter } from './'; +import { EngineRouter } from './engine_router'; describe('EngineRouter', () => { const values = { + ...mockEngineValues, dataLoading: false, engineNotFound: false, myRole: {}, - engineName: 'some-engine', }; const actions = { setEngineName: jest.fn(), initializeEngine: jest.fn(), clearEngine: jest.fn() }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx index 47fe302ac70147..fd21507a427d5c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx @@ -17,7 +17,6 @@ import { AppLogic } from '../../app_logic'; // TODO: Uncomment and add more routes as we migrate them import { ENGINES_PATH, - ENGINE_PATH, ENGINE_ANALYTICS_PATH, ENGINE_DOCUMENTS_PATH, ENGINE_DOCUMENT_DETAIL_PATH, @@ -86,14 +85,14 @@ export const EngineRouter: React.FC = () => { return ( {canViewEngineAnalytics && ( - + )} - + - + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/recent_api_logs.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/recent_api_logs.test.tsx index fb34682e3c7ecd..9da63ca639bbfd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/recent_api_logs.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/recent_api_logs.test.tsx @@ -5,6 +5,7 @@ */ import { setMockValues } from '../../../../__mocks__/kea.mock'; +import { mockEngineValues } from '../../../__mocks__'; import React from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; @@ -18,9 +19,7 @@ describe('RecentApiLogs', () => { beforeAll(() => { jest.clearAllMocks(); - setMockValues({ - engineName: 'some-engine', - }); + setMockValues(mockEngineValues); wrapper = shallow(); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/recent_api_logs.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/recent_api_logs.tsx index 3f42419252d282..19c931cefc1e39 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/recent_api_logs.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/recent_api_logs.tsx @@ -16,16 +16,14 @@ import { } from '@elastic/eui'; import { EuiButtonTo } from '../../../../shared/react_router_helpers'; +import { ENGINE_API_LOGS_PATH } from '../../../routes'; +import { EngineLogic } from '../../engine'; -import { ENGINE_API_LOGS_PATH, getEngineRoute } from '../../../routes'; import { RECENT_API_EVENTS } from '../../api_logs/constants'; import { VIEW_API_LOGS } from '../constants'; -import { EngineLogic } from '../../engine'; - export const RecentApiLogs: React.FC = () => { - const { engineName } = useValues(EngineLogic); - const engineRoute = getEngineRoute(engineName); + const { generateEnginePath } = useValues(EngineLogic); return ( @@ -36,7 +34,7 @@ export const RecentApiLogs: React.FC = () => { - + {VIEW_API_LOGS} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/total_charts.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/total_charts.test.tsx index b1350b7e102e36..98718dea7130f6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/total_charts.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/total_charts.test.tsx @@ -5,6 +5,7 @@ */ import { setMockValues } from '../../../../__mocks__/kea.mock'; +import { mockEngineValues } from '../../../__mocks__'; import React from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; @@ -20,7 +21,7 @@ describe('TotalCharts', () => { beforeAll(() => { jest.clearAllMocks(); setMockValues({ - engineName: 'some-engine', + ...mockEngineValues, startDate: '1970-01-01', queriesPerDay: [0, 1, 2, 3, 5, 10, 50], operationsPerDay: [0, 0, 0, 0, 0, 0, 0], diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/total_charts.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/total_charts.tsx index 4ef4e08dee7611..02453cc8a150f0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/total_charts.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/total_charts.tsx @@ -19,20 +19,16 @@ import { } from '@elastic/eui'; import { EuiButtonTo } from '../../../../shared/react_router_helpers'; +import { ENGINE_ANALYTICS_PATH, ENGINE_API_LOGS_PATH } from '../../../routes'; +import { EngineLogic } from '../../engine'; -import { ENGINE_ANALYTICS_PATH, ENGINE_API_LOGS_PATH, getEngineRoute } from '../../../routes'; import { TOTAL_QUERIES, TOTAL_API_OPERATIONS } from '../../analytics/constants'; import { VIEW_ANALYTICS, VIEW_API_LOGS, LAST_7_DAYS } from '../constants'; - import { AnalyticsChart, convertToChartData } from '../../analytics'; - -import { EngineLogic } from '../../engine'; import { EngineOverviewLogic } from '../'; export const TotalCharts: React.FC = () => { - const { engineName } = useValues(EngineLogic); - const engineRoute = getEngineRoute(engineName); - + const { generateEnginePath } = useValues(EngineLogic); const { startDate, queriesPerDay, operationsPerDay } = useValues(EngineOverviewLogic); return ( @@ -49,7 +45,7 @@ export const TotalCharts: React.FC = () => { - + {VIEW_ANALYTICS} @@ -78,7 +74,7 @@ export const TotalCharts: React.FC = () => { - + {VIEW_API_LOGS} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.test.tsx index 1dde4db15a4254..a0f150ca4ec424 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.test.tsx @@ -4,9 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import '../../../__mocks__/kea.mock'; import '../../../__mocks__/enterprise_search_url.mock'; -import { mockTelemetryActions, mountWithIntl } from '../../../__mocks__/'; +import { mockTelemetryActions, mountWithIntl } from '../../../__mocks__'; import React from 'react'; import { EuiBasicTable, EuiPagination, EuiButtonEmpty } from '@elastic/eui'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.tsx index e8944c37efa479..a9455b4a2306ab 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.tsx @@ -5,6 +5,7 @@ */ import React from 'react'; +import { generatePath } from 'react-router-dom'; import { useActions } from 'kea'; import { EuiBasicTable, EuiBasicTableColumn } from '@elastic/eui'; import { FormattedMessage, FormattedDate, FormattedNumber } from '@kbn/i18n/react'; @@ -12,7 +13,7 @@ import { i18n } from '@kbn/i18n'; import { TelemetryLogic } from '../../../shared/telemetry'; import { EuiLinkTo } from '../../../shared/react_router_helpers'; -import { getEngineRoute } from '../../routes'; +import { ENGINE_PATH } from '../../routes'; import { ENGINES_PAGE_SIZE } from '../../../../../common/constants'; import { UNIVERSAL_LANGUAGE } from '../../constants'; @@ -39,8 +40,8 @@ export const EnginesTable: React.FC = ({ }) => { const { sendAppSearchTelemetry } = useActions(TelemetryLogic); - const engineLinkProps = (name: string) => ({ - to: getEngineRoute(name), + const engineLinkProps = (engineName: string) => ({ + to: generatePath(ENGINE_PATH, { engineName }), onClick: () => sendAppSearchTelemetry({ action: 'clicked', diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.tsx index f25eb2a4ba09ee..a3935bb782f906 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.tsx @@ -5,6 +5,7 @@ */ import React, { useState, useMemo } from 'react'; +import { generatePath } from 'react-router-dom'; import classNames from 'classnames'; import './result.scss'; @@ -12,12 +13,13 @@ import './result.scss'; import { EuiPanel, EuiIcon } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { ReactRouterHelper } from '../../../shared/react_router_helpers/eui_components'; +import { ENGINE_DOCUMENT_DETAIL_PATH } from '../../routes'; + +import { Schema } from '../../../shared/types'; import { FieldValue, Result as ResultType } from './types'; import { ResultField } from './result_field'; import { ResultHeader } from './result_header'; -import { getDocumentDetailRoute } from '../../routes'; -import { ReactRouterHelper } from '../../../shared/react_router_helpers/eui_components'; -import { Schema } from '../../../shared/types'; interface Props { result: ResultType; @@ -50,7 +52,10 @@ export const Result: React.FC = ({ if (schemaForTypeHighlights) return schemaForTypeHighlights[fieldName]; }; - const documentLink = getDocumentDetailRoute(resultMeta.engine, resultMeta.id); + const documentLink = generatePath(ENGINE_DOCUMENT_DETAIL_PATH, { + engineName: resultMeta.engine, + documentId: resultMeta.id, + }); const conditionallyLinkedArticle = (children: React.ReactNode) => { return shouldLinkToDetailPage ? ( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts index 0f3d34cfa6337e..41e9bfa19e0f0f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts @@ -4,8 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { generatePath } from 'react-router-dom'; - import { CURRENT_MAJOR_VERSION } from '../../../common/version'; export const DOCS_PREFIX = `https://www.elastic.co/guide/en/app-search/${CURRENT_MAJOR_VERSION}`; @@ -20,11 +18,10 @@ export const ROLE_MAPPINGS_PATH = '#/role-mappings'; // This page seems to 404 i export const ENGINES_PATH = '/engines'; export const CREATE_ENGINES_PATH = `${ENGINES_PATH}/new`; -export const ENGINE_PATH = '/engines/:engineName'; -export const SAMPLE_ENGINE_PATH = '/engines/national-parks-demo'; -export const getEngineRoute = (engineName: string) => generatePath(ENGINE_PATH, { engineName }); +export const ENGINE_PATH = `${ENGINES_PATH}/:engineName`; +export const SAMPLE_ENGINE_PATH = `${ENGINES_PATH}/national-parks-demo`; -export const ENGINE_ANALYTICS_PATH = '/analytics'; +export const ENGINE_ANALYTICS_PATH = `${ENGINE_PATH}/analytics`; export const ENGINE_ANALYTICS_TOP_QUERIES_PATH = `${ENGINE_ANALYTICS_PATH}/top_queries`; export const ENGINE_ANALYTICS_TOP_QUERIES_NO_CLICKS_PATH = `${ENGINE_ANALYTICS_PATH}/top_queries_no_clicks`; export const ENGINE_ANALYTICS_TOP_QUERIES_NO_RESULTS_PATH = `${ENGINE_ANALYTICS_PATH}/top_queries_no_results`; @@ -33,25 +30,22 @@ export const ENGINE_ANALYTICS_RECENT_QUERIES_PATH = `${ENGINE_ANALYTICS_PATH}/re export const ENGINE_ANALYTICS_QUERY_DETAILS_PATH = `${ENGINE_ANALYTICS_PATH}/query_detail`; export const ENGINE_ANALYTICS_QUERY_DETAIL_PATH = `${ENGINE_ANALYTICS_QUERY_DETAILS_PATH}/:query`; -export const ENGINE_DOCUMENTS_PATH = '/documents'; +export const ENGINE_DOCUMENTS_PATH = `${ENGINE_PATH}/documents`; export const ENGINE_DOCUMENT_DETAIL_PATH = `${ENGINE_DOCUMENTS_PATH}/:documentId`; -export const getDocumentDetailRoute = (engineName: string, documentId: string) => { - return generatePath(ENGINE_PATH + ENGINE_DOCUMENT_DETAIL_PATH, { engineName, documentId }); -}; -export const ENGINE_SCHEMA_PATH = '/schema/edit'; -export const ENGINE_REINDEX_JOB_PATH = '/reindex-job/:activeReindexJobId'; +export const ENGINE_SCHEMA_PATH = `${ENGINE_PATH}/schema/edit`; +export const ENGINE_REINDEX_JOB_PATH = `${ENGINE_PATH}/reindex-job/:activeReindexJobId`; -export const ENGINE_CRAWLER_PATH = '/crawler'; +export const ENGINE_CRAWLER_PATH = `${ENGINE_PATH}/crawler`; // TODO: Crawler sub-pages -export const META_ENGINE_SOURCE_ENGINES_PATH = '/engines'; +export const META_ENGINE_SOURCE_ENGINES_PATH = `${ENGINE_PATH}/engines`; -export const ENGINE_RELEVANCE_TUNING_PATH = '/search-settings'; -export const ENGINE_SYNONYMS_PATH = '/synonyms'; -export const ENGINE_CURATIONS_PATH = '/curations'; +export const ENGINE_RELEVANCE_TUNING_PATH = `${ENGINE_PATH}/search-settings`; +export const ENGINE_SYNONYMS_PATH = `${ENGINE_PATH}/synonyms`; +export const ENGINE_CURATIONS_PATH = `${ENGINE_PATH}/curations`; // TODO: Curations sub-pages -export const ENGINE_RESULT_SETTINGS_PATH = '/result-settings'; +export const ENGINE_RESULT_SETTINGS_PATH = `${ENGINE_PATH}/result-settings`; -export const ENGINE_SEARCH_UI_PATH = '/reference_application/new'; -export const ENGINE_API_LOGS_PATH = '/api-logs'; +export const ENGINE_SEARCH_UI_PATH = `${ENGINE_PATH}/reference_application/new`; +export const ENGINE_API_LOGS_PATH = `${ENGINE_PATH}/api-logs`; From 17043d4f9d699855852841449dfe2f6c4ad377e4 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Thu, 21 Jan 2021 11:25:02 -0800 Subject: [PATCH 07/16] Use doc link service in Stack Monitoring (#88920) --- src/core/public/doc_links/doc_links_service.ts | 5 +++++ .../public/alerts/ccr_read_exceptions_alert/index.tsx | 2 +- .../public/alerts/cpu_usage_alert/cpu_usage_alert.tsx | 2 +- .../monitoring/public/alerts/disk_usage_alert/index.tsx | 2 +- .../monitoring/public/alerts/legacy_alert/legacy_alert.tsx | 2 +- .../monitoring/public/alerts/memory_usage_alert/index.tsx | 2 +- .../missing_monitoring_data_alert.tsx | 2 +- .../public/alerts/thread_pool_rejections_alert/index.tsx | 2 +- 8 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 1a69c7db35a73c..b82254e5a14166 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -182,7 +182,12 @@ export class DocLinksService { guide: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/maps.html`, }, monitoring: { + alertsCluster: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/cluster-alerts.html`, alertsKibana: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/kibana-alerts.html`, + alertsKibanaCpuThreshold: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/kibana-alerts.html#kibana-alerts-cpu-threshold`, + alertsKibanaDiskThreshold: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/kibana-alerts.html#kibana-alerts-disk-usage-threshold`, + alertsKibanaJvmThreshold: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/kibana-alerts.html#kibana-alerts-jvm-memory-threshold`, + alertsKibanaMissingData: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/kibana-alerts.html#kibana-alerts-missing-monitoring-data`, monitorElasticsearch: `${ELASTICSEARCH_DOCS}configuring-metricbeat.html`, monitorKibana: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/monitoring-metricbeat.html`, }, diff --git a/x-pack/plugins/monitoring/public/alerts/ccr_read_exceptions_alert/index.tsx b/x-pack/plugins/monitoring/public/alerts/ccr_read_exceptions_alert/index.tsx index 6d7751d91b7619..e656c0ab253e02 100644 --- a/x-pack/plugins/monitoring/public/alerts/ccr_read_exceptions_alert/index.tsx +++ b/x-pack/plugins/monitoring/public/alerts/ccr_read_exceptions_alert/index.tsx @@ -37,7 +37,7 @@ export function createCCRReadExceptionsAlertType(): AlertTypeModel ( diff --git a/x-pack/plugins/monitoring/public/alerts/cpu_usage_alert/cpu_usage_alert.tsx b/x-pack/plugins/monitoring/public/alerts/cpu_usage_alert/cpu_usage_alert.tsx index d2cec006b1b1d5..9b207457683f64 100644 --- a/x-pack/plugins/monitoring/public/alerts/cpu_usage_alert/cpu_usage_alert.tsx +++ b/x-pack/plugins/monitoring/public/alerts/cpu_usage_alert/cpu_usage_alert.tsx @@ -16,7 +16,7 @@ export function createCpuUsageAlertType(): AlertTypeModel ( diff --git a/x-pack/plugins/monitoring/public/alerts/disk_usage_alert/index.tsx b/x-pack/plugins/monitoring/public/alerts/disk_usage_alert/index.tsx index bea399ee89f6a3..aeb9bab2aae9aa 100644 --- a/x-pack/plugins/monitoring/public/alerts/disk_usage_alert/index.tsx +++ b/x-pack/plugins/monitoring/public/alerts/disk_usage_alert/index.tsx @@ -18,7 +18,7 @@ export function createDiskUsageAlertType(): AlertTypeModel ( diff --git a/x-pack/plugins/monitoring/public/alerts/legacy_alert/legacy_alert.tsx b/x-pack/plugins/monitoring/public/alerts/legacy_alert/legacy_alert.tsx index d50e9c3a5c2828..4a3532ad612407 100644 --- a/x-pack/plugins/monitoring/public/alerts/legacy_alert/legacy_alert.tsx +++ b/x-pack/plugins/monitoring/public/alerts/legacy_alert/legacy_alert.tsx @@ -18,7 +18,7 @@ export function createLegacyAlertTypes(): AlertTypeModel[] { description: LEGACY_ALERT_DETAILS[legacyAlert].description, iconClass: 'bell', documentationUrl(docLinks) { - return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/cluster-alerts.html`; + return `${docLinks.links.monitoring.alertsCluster}`; }, alertParamsExpression: () => ( diff --git a/x-pack/plugins/monitoring/public/alerts/memory_usage_alert/index.tsx b/x-pack/plugins/monitoring/public/alerts/memory_usage_alert/index.tsx index 0428e4e7c733e4..b484cd9a975fd2 100644 --- a/x-pack/plugins/monitoring/public/alerts/memory_usage_alert/index.tsx +++ b/x-pack/plugins/monitoring/public/alerts/memory_usage_alert/index.tsx @@ -18,7 +18,7 @@ export function createMemoryUsageAlertType(): AlertTypeModel ( diff --git a/x-pack/plugins/monitoring/public/alerts/missing_monitoring_data_alert/missing_monitoring_data_alert.tsx b/x-pack/plugins/monitoring/public/alerts/missing_monitoring_data_alert/missing_monitoring_data_alert.tsx index fdb89033c4e2ca..18a4990eeaaa73 100644 --- a/x-pack/plugins/monitoring/public/alerts/missing_monitoring_data_alert/missing_monitoring_data_alert.tsx +++ b/x-pack/plugins/monitoring/public/alerts/missing_monitoring_data_alert/missing_monitoring_data_alert.tsx @@ -16,7 +16,7 @@ export function createMissingMonitoringDataAlertType(): AlertTypeModel { description: ALERT_DETAILS[ALERT_MISSING_MONITORING_DATA].description, iconClass: 'bell', documentationUrl(docLinks) { - return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/kibana-alerts.html#kibana-alerts-missing-monitoring-data`; + return `${docLinks.links.monitoring.alertsKibanaMissingData}`; }, alertParamsExpression: (props: any) => ( ( <> From 76b23f17e2035185aa7bd213af2767b564302a1d Mon Sep 17 00:00:00 2001 From: Sandra Gonzales Date: Thu, 21 Jan 2021 14:32:35 -0500 Subject: [PATCH 08/16] add custom metrics to node tooltip (#88545) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../conditional_tooltip.test.tsx.snap | 42 +++++++++- .../waffle/conditional_tooltip.test.tsx | 82 ++++++++++++++++++- .../components/waffle/conditional_tooltip.tsx | 32 ++++++-- 3 files changed, 143 insertions(+), 13 deletions(-) diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/__snapshots__/conditional_tooltip.test.tsx.snap b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/__snapshots__/conditional_tooltip.test.tsx.snap index b8cdc0acac1dc0..a5d97813e4b14a 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/__snapshots__/conditional_tooltip.test.tsx.snap +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/__snapshots__/conditional_tooltip.test.tsx.snap @@ -21,9 +21,10 @@ exports[`ConditionalToolTip should just work 1`] = ` host-01 CPU usage @@ -35,9 +36,10 @@ exports[`ConditionalToolTip should just work 1`] = ` Memory usage @@ -49,9 +51,10 @@ exports[`ConditionalToolTip should just work 1`] = ` Outbound traffic @@ -63,9 +66,10 @@ exports[`ConditionalToolTip should just work 1`] = ` Inbound traffic @@ -76,5 +80,35 @@ exports[`ConditionalToolTip should just work 1`] = ` 8Mbit/s + + + My Custom Label + + + 34.1% + + + + + Avg of host.network.out.packets + + + 4,392.2 + + `; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.test.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.test.tsx index e01ca3ab6e8446..fbca85e2d44962 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.test.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.test.tsx @@ -22,7 +22,12 @@ jest.mock('../../../../../containers/source', () => ({ jest.mock('../../hooks/use_snaphot'); import { useSnapshot } from '../../hooks/use_snaphot'; +jest.mock('../../hooks/use_waffle_options'); +import { useWaffleOptionsContext } from '../../hooks/use_waffle_options'; const mockedUseSnapshot = useSnapshot as jest.Mock>; +const mockedUseWaffleOptionsContext = useWaffleOptionsContext as jest.Mock< + ReturnType +>; const NODE: InfraWaffleMapNode = { pathId: 'host-01', @@ -50,6 +55,7 @@ const ChildComponent = () =>
child
; describe('ConditionalToolTip', () => { afterEach(() => { mockedUseSnapshot.mockReset(); + mockedUseWaffleOptionsContext.mockReset(); }); function createWrapper(currentTime: number = Date.now(), hidden: boolean = false) { @@ -77,6 +83,7 @@ describe('ConditionalToolTip', () => { interval: '', reload: jest.fn(() => Promise.resolve()), }); + mockedUseWaffleOptionsContext.mockReturnValue(mockedUseWaffleOptionsContexReturnValue); const currentTime = Date.now(); const wrapper = createWrapper(currentTime, true); expect(wrapper.find(ChildComponent).exists()).toBeTruthy(); @@ -95,6 +102,18 @@ describe('ConditionalToolTip', () => { { name: 'memory', value: 0.8, avg: 0.8, max: 1 }, { name: 'tx', value: 1000000, avg: 1000000, max: 1000000 }, { name: 'rx', value: 1000000, avg: 1000000, max: 1000000 }, + { + name: 'cedd6ca0-5775-11eb-a86f-adb714b6c486', + max: 0.34164999922116596, + value: 0.34140000740687054, + avg: 0.20920833365784752, + }, + { + name: 'e12dd700-5775-11eb-a86f-adb714b6c486', + max: 4703.166666666667, + value: 4392.166666666667, + avg: 3704.6666666666674, + }, ], }, ], @@ -103,6 +122,7 @@ describe('ConditionalToolTip', () => { interval: '60s', reload: reloadMock, }); + mockedUseWaffleOptionsContext.mockReturnValue(mockedUseWaffleOptionsContexReturnValue); const currentTime = Date.now(); const wrapper = createWrapper(currentTime, false); expect(wrapper.find(ChildComponent).exists()).toBeTruthy(); @@ -114,7 +134,25 @@ describe('ConditionalToolTip', () => { }, }, }); - const expectedMetrics = [{ type: 'cpu' }, { type: 'memory' }, { type: 'tx' }, { type: 'rx' }]; + const expectedMetrics = [ + { type: 'cpu' }, + { type: 'memory' }, + { type: 'tx' }, + { type: 'rx' }, + { + aggregation: 'avg', + field: 'host.cpu.pct', + id: 'cedd6ca0-5775-11eb-a86f-adb714b6c486', + label: 'My Custom Label', + type: 'custom', + }, + { + aggregation: 'avg', + field: 'host.network.out.packets', + id: 'e12dd700-5775-11eb-a86f-adb714b6c486', + type: 'custom', + }, + ]; expect(mockedUseSnapshot).toBeCalledWith( expectedQuery, expectedMetrics, @@ -143,6 +181,7 @@ describe('ConditionalToolTip', () => { interval: '', reload: reloadMock, }); + mockedUseWaffleOptionsContext.mockReturnValue(mockedUseWaffleOptionsContexReturnValue); const currentTime = Date.now(); const wrapper = createWrapper(currentTime, false); expect(wrapper.find(ChildComponent).exists()).toBeTruthy(); @@ -154,3 +193,44 @@ describe('ConditionalToolTip', () => { expect(reloadMock).not.toHaveBeenCalled(); }); }); + +const mockedUseWaffleOptionsContexReturnValue: ReturnType = { + changeMetric: jest.fn(() => {}), + changeGroupBy: jest.fn(() => {}), + changeNodeType: jest.fn(() => {}), + changeView: jest.fn(() => {}), + changeCustomOptions: jest.fn(() => {}), + changeAutoBounds: jest.fn(() => {}), + changeBoundsOverride: jest.fn(() => {}), + changeAccount: jest.fn(() => {}), + changeRegion: jest.fn(() => {}), + changeCustomMetrics: jest.fn(() => {}), + changeLegend: jest.fn(() => {}), + changeSort: jest.fn(() => {}), + setWaffleOptionsState: jest.fn(() => {}), + boundsOverride: { max: 1, min: 0 }, + autoBounds: true, + accountId: '', + region: '', + sort: { by: 'name', direction: 'desc' }, + groupBy: [], + nodeType: 'host', + customOptions: [], + view: 'map', + metric: { type: 'cpu' }, + customMetrics: [ + { + aggregation: 'avg', + field: 'host.cpu.pct', + id: 'cedd6ca0-5775-11eb-a86f-adb714b6c486', + label: 'My Custom Label', + type: 'custom', + }, + { + aggregation: 'avg', + field: 'host.network.out.packets', + id: 'e12dd700-5775-11eb-a86f-adb714b6c486', + type: 'custom', + }, + ], +}; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.tsx index 8082752a88b7f6..7ec1ae905a640f 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.tsx @@ -6,6 +6,8 @@ import React, { useCallback, useState, useEffect } from 'react'; import { EuiToolTip, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { first } from 'lodash'; +import { getCustomMetricLabel } from '../../../../../../common/formatters/get_custom_metric_label'; +import { SnapshotCustomMetricInput } from '../../../../../../common/http_api'; import { withTheme, EuiTheme } from '../../../../../../../observability/public'; import { useSourceContext } from '../../../../../containers/source'; import { findInventoryModel } from '../../../../../../common/inventory_models'; @@ -18,6 +20,8 @@ import { InfraWaffleMapNode, InfraWaffleMapOptions } from '../../../../../lib/li import { useSnapshot } from '../../hooks/use_snaphot'; import { createInventoryMetricFormatter } from '../../lib/create_inventory_metric_formatter'; import { SNAPSHOT_METRIC_TRANSLATIONS } from '../../../../../../common/inventory_models/intl_strings'; +import { useWaffleOptionsContext } from '../../hooks/use_waffle_options'; +import { createFormatterForMetric } from '../../../metrics_explorer/components/helpers/create_formatter_for_metric'; export interface Props { currentTime: number; @@ -35,9 +39,15 @@ export const ConditionalToolTip = withTheme( const { sourceId } = useSourceContext(); const [timer, setTimer] = useState | null>(null); const model = findInventoryModel(nodeType); - const requestMetrics = model.tooltipMetrics.map((type) => ({ type })) as Array<{ - type: SnapshotMetricType; - }>; + const { customMetrics } = useWaffleOptionsContext(); + const requestMetrics = model.tooltipMetrics + .map((type) => ({ type })) + .concat(customMetrics) as Array< + | { + type: SnapshotMetricType; + } + | SnapshotCustomMetricInput + >; const query = JSON.stringify({ bool: { filter: { @@ -45,7 +55,6 @@ export const ConditionalToolTip = withTheme( }, }, }); - const { nodes, reload } = useSnapshot( query, requestMetrics, @@ -74,7 +83,6 @@ export const ConditionalToolTip = withTheme( if (hidden) { return children; } - const dataNode = first(nodes); const metrics = (dataNode && dataNode.metrics) || []; const content = ( @@ -91,10 +99,18 @@ export const ConditionalToolTip = withTheme( {metrics.map((metric) => { const metricName = SnapshotMetricTypeRT.is(metric.name) ? metric.name : 'custom'; const name = SNAPSHOT_METRIC_TRANSLATIONS[metricName] || metricName; - const formatter = createInventoryMetricFormatter({ type: metricName }); + // if custom metric, find field and label from waffleOptionsContext result + // because useSnapshot does not return it + const customMetric = + name === 'custom' ? customMetrics.find((item) => item.id === metric.name) : null; + const formatter = customMetric + ? createFormatterForMetric(customMetric) + : createInventoryMetricFormatter({ type: metricName }); return ( - - {name} + + + {customMetric ? getCustomMetricLabel(customMetric) : name} + {(metric.value && formatter(metric.value)) || '-'} From ae0bd2fbba07901445260a0cbd264da6017eb152 Mon Sep 17 00:00:00 2001 From: Matthew Kime Date: Thu, 21 Jan 2021 14:10:19 -0600 Subject: [PATCH 09/16] Add runtime fields to index patterns and searchsource (#88542) * Add runtime fields to index patterns and searchsource --- ...ata-public.indexpattern.addruntimefield.md | 25 +++++ ...ublic.indexpattern.getassavedobjectbody.md | 2 + ...a-public.indexpattern.getcomputedfields.md | 2 + ...plugin-plugins-data-public.indexpattern.md | 2 + ...-public.indexpattern.removeruntimefield.md | 24 +++++ ...gins-data-public.indexpatternattributes.md | 1 + ....indexpatternattributes.runtimefieldmap.md | 11 +++ ...-data-public.indexpatternfield.ismapped.md | 13 +++ ...n-plugins-data-public.indexpatternfield.md | 2 + ...a-public.indexpatternfield.runtimefield.md | 13 +++ ...in-plugins-data-public.indexpatternspec.md | 1 + ...public.indexpatternspec.runtimefieldmap.md | 11 +++ ...ata-server.indexpattern.addruntimefield.md | 25 +++++ ...erver.indexpattern.getassavedobjectbody.md | 2 + ...a-server.indexpattern.getcomputedfields.md | 2 + ...plugin-plugins-data-server.indexpattern.md | 2 + ...-server.indexpattern.removeruntimefield.md | 24 +++++ ...gins-data-server.indexpatternattributes.md | 1 + ....indexpatternattributes.runtimefieldmap.md | 11 +++ .../index_pattern_field.test.ts.snap | 7 ++ .../fields/index_pattern_field.test.ts | 8 +- .../fields/index_pattern_field.ts | 20 +++- .../__snapshots__/index_pattern.test.ts.snap | 93 +++++++++++++++++++ .../__snapshots__/index_patterns.test.ts.snap | 1 + .../fixtures/logstash_fields.js | 1 + .../index_patterns/index_pattern.test.ts | 86 ++++++++++++++++- .../index_patterns/index_pattern.ts | 57 +++++++++++- .../index_patterns/index_patterns.ts | 29 +++++- .../data/common/index_patterns/types.ts | 12 +++ .../search_source/search_source.test.ts | 22 ++++- .../search/search_source/search_source.ts | 15 ++- src/plugins/data/public/public.api.md | 17 +++- src/plugins/data/server/server.api.md | 13 ++- .../helpers/get_sharing_data.test.ts | 1 + .../test/functional/apps/maps/mvt_scaling.js | 2 +- .../functional/apps/maps/mvt_super_fine.js | 2 +- 36 files changed, 542 insertions(+), 18 deletions(-) create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.addruntimefield.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.removeruntimefield.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternattributes.runtimefieldmap.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.ismapped.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.runtimefield.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternspec.runtimefieldmap.md create mode 100644 docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpattern.addruntimefield.md create mode 100644 docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpattern.removeruntimefield.md create mode 100644 docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternattributes.runtimefieldmap.md diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.addruntimefield.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.addruntimefield.md new file mode 100644 index 00000000000000..5640395139ba69 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.addruntimefield.md @@ -0,0 +1,25 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPattern](./kibana-plugin-plugins-data-public.indexpattern.md) > [addRuntimeField](./kibana-plugin-plugins-data-public.indexpattern.addruntimefield.md) + +## IndexPattern.addRuntimeField() method + +Add a runtime field - Appended to existing mapped field or a new field is created as appropriate + +Signature: + +```typescript +addRuntimeField(name: string, runtimeField: RuntimeField): void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| name | string | | +| runtimeField | RuntimeField | | + +Returns: + +`void` + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.getassavedobjectbody.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.getassavedobjectbody.md index b318427012c0ac..48d94b84497bd9 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.getassavedobjectbody.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.getassavedobjectbody.md @@ -20,6 +20,7 @@ getAsSavedObjectBody(): { type: string | undefined; typeMeta: string | undefined; allowNoIndex: true | undefined; + runtimeFieldMap: string | undefined; }; ``` Returns: @@ -35,5 +36,6 @@ getAsSavedObjectBody(): { type: string | undefined; typeMeta: string | undefined; allowNoIndex: true | undefined; + runtimeFieldMap: string | undefined; }` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.getcomputedfields.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.getcomputedfields.md index 84aeb9ffeb21a0..37d31a35167dfc 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.getcomputedfields.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.getcomputedfields.md @@ -14,6 +14,7 @@ getComputedFields(): { field: any; format: string; }[]; + runtimeFields: Record; }; ``` Returns: @@ -25,5 +26,6 @@ getComputedFields(): { field: any; format: string; }[]; + runtimeFields: Record; }` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.md index 872e23e450f881..53d173d39f50d0 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.md @@ -45,6 +45,7 @@ export declare class IndexPattern implements IIndexPattern | Method | Modifiers | Description | | --- | --- | --- | +| [addRuntimeField(name, runtimeField)](./kibana-plugin-plugins-data-public.indexpattern.addruntimefield.md) | | Add a runtime field - Appended to existing mapped field or a new field is created as appropriate | | [addScriptedField(name, script, fieldType)](./kibana-plugin-plugins-data-public.indexpattern.addscriptedfield.md) | | Add scripted field to field list | | [getAggregationRestrictions()](./kibana-plugin-plugins-data-public.indexpattern.getaggregationrestrictions.md) | | | | [getAsSavedObjectBody()](./kibana-plugin-plugins-data-public.indexpattern.getassavedobjectbody.md) | | Returns index pattern as saved object body for saving | @@ -58,6 +59,7 @@ export declare class IndexPattern implements IIndexPattern | [getTimeField()](./kibana-plugin-plugins-data-public.indexpattern.gettimefield.md) | | | | [isTimeBased()](./kibana-plugin-plugins-data-public.indexpattern.istimebased.md) | | | | [isTimeNanosBased()](./kibana-plugin-plugins-data-public.indexpattern.istimenanosbased.md) | | | +| [removeRuntimeField(name)](./kibana-plugin-plugins-data-public.indexpattern.removeruntimefield.md) | | Remove a runtime field - removed from mapped field or removed unmapped field as appropriate | | [removeScriptedField(fieldName)](./kibana-plugin-plugins-data-public.indexpattern.removescriptedfield.md) | | Remove scripted field from field list | | [setFieldAttrs(fieldName, attrName, value)](./kibana-plugin-plugins-data-public.indexpattern.setfieldattrs.md) | | | | [setFieldCount(fieldName, count)](./kibana-plugin-plugins-data-public.indexpattern.setfieldcount.md) | | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.removeruntimefield.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.removeruntimefield.md new file mode 100644 index 00000000000000..7a5228fece782e --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.removeruntimefield.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPattern](./kibana-plugin-plugins-data-public.indexpattern.md) > [removeRuntimeField](./kibana-plugin-plugins-data-public.indexpattern.removeruntimefield.md) + +## IndexPattern.removeRuntimeField() method + +Remove a runtime field - removed from mapped field or removed unmapped field as appropriate + +Signature: + +```typescript +removeRuntimeField(name: string): void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| name | string | | + +Returns: + +`void` + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternattributes.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternattributes.md index 297bfa855f0ebc..41a4d3c55694b3 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternattributes.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternattributes.md @@ -21,6 +21,7 @@ export interface IndexPatternAttributes | [fieldFormatMap](./kibana-plugin-plugins-data-public.indexpatternattributes.fieldformatmap.md) | string | | | [fields](./kibana-plugin-plugins-data-public.indexpatternattributes.fields.md) | string | | | [intervalName](./kibana-plugin-plugins-data-public.indexpatternattributes.intervalname.md) | string | | +| [runtimeFieldMap](./kibana-plugin-plugins-data-public.indexpatternattributes.runtimefieldmap.md) | string | | | [sourceFilters](./kibana-plugin-plugins-data-public.indexpatternattributes.sourcefilters.md) | string | | | [timeFieldName](./kibana-plugin-plugins-data-public.indexpatternattributes.timefieldname.md) | string | | | [title](./kibana-plugin-plugins-data-public.indexpatternattributes.title.md) | string | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternattributes.runtimefieldmap.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternattributes.runtimefieldmap.md new file mode 100644 index 00000000000000..0df7a9841e41f3 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternattributes.runtimefieldmap.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternAttributes](./kibana-plugin-plugins-data-public.indexpatternattributes.md) > [runtimeFieldMap](./kibana-plugin-plugins-data-public.indexpatternattributes.runtimefieldmap.md) + +## IndexPatternAttributes.runtimeFieldMap property + +Signature: + +```typescript +runtimeFieldMap?: string; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.ismapped.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.ismapped.md new file mode 100644 index 00000000000000..653a1f2b39c290 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.ismapped.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [isMapped](./kibana-plugin-plugins-data-public.indexpatternfield.ismapped.md) + +## IndexPatternField.isMapped property + +Is the field part of the index mapping? + +Signature: + +```typescript +get isMapped(): boolean | undefined; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.md index c8118770ed3944..05c807b1cd8457 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.md @@ -27,9 +27,11 @@ export declare class IndexPatternField implements IFieldType | [displayName](./kibana-plugin-plugins-data-public.indexpatternfield.displayname.md) | | string | | | [esTypes](./kibana-plugin-plugins-data-public.indexpatternfield.estypes.md) | | string[] | undefined | | | [filterable](./kibana-plugin-plugins-data-public.indexpatternfield.filterable.md) | | boolean | | +| [isMapped](./kibana-plugin-plugins-data-public.indexpatternfield.ismapped.md) | | boolean | undefined | Is the field part of the index mapping? | | [lang](./kibana-plugin-plugins-data-public.indexpatternfield.lang.md) | | string | undefined | Script field language | | [name](./kibana-plugin-plugins-data-public.indexpatternfield.name.md) | | string | | | [readFromDocValues](./kibana-plugin-plugins-data-public.indexpatternfield.readfromdocvalues.md) | | boolean | | +| [runtimeField](./kibana-plugin-plugins-data-public.indexpatternfield.runtimefield.md) | | RuntimeField | undefined | | | [script](./kibana-plugin-plugins-data-public.indexpatternfield.script.md) | | string | undefined | Script field code | | [scripted](./kibana-plugin-plugins-data-public.indexpatternfield.scripted.md) | | boolean | | | [searchable](./kibana-plugin-plugins-data-public.indexpatternfield.searchable.md) | | boolean | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.runtimefield.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.runtimefield.md new file mode 100644 index 00000000000000..ad3b81eb23edce --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.runtimefield.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [runtimeField](./kibana-plugin-plugins-data-public.indexpatternfield.runtimefield.md) + +## IndexPatternField.runtimeField property + +Signature: + +```typescript +get runtimeField(): RuntimeField | undefined; + +set runtimeField(runtimeField: RuntimeField | undefined); +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternspec.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternspec.md index c0fa165cfb115a..ae514e3fc6a8a3 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternspec.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternspec.md @@ -22,6 +22,7 @@ export interface IndexPatternSpec | [fields](./kibana-plugin-plugins-data-public.indexpatternspec.fields.md) | IndexPatternFieldMap | | | [id](./kibana-plugin-plugins-data-public.indexpatternspec.id.md) | string | saved object id | | [intervalName](./kibana-plugin-plugins-data-public.indexpatternspec.intervalname.md) | string | | +| [runtimeFieldMap](./kibana-plugin-plugins-data-public.indexpatternspec.runtimefieldmap.md) | Record<string, RuntimeField> | | | [sourceFilters](./kibana-plugin-plugins-data-public.indexpatternspec.sourcefilters.md) | SourceFilter[] | | | [timeFieldName](./kibana-plugin-plugins-data-public.indexpatternspec.timefieldname.md) | string | | | [title](./kibana-plugin-plugins-data-public.indexpatternspec.title.md) | string | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternspec.runtimefieldmap.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternspec.runtimefieldmap.md new file mode 100644 index 00000000000000..e208760ff188f3 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternspec.runtimefieldmap.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternSpec](./kibana-plugin-plugins-data-public.indexpatternspec.md) > [runtimeFieldMap](./kibana-plugin-plugins-data-public.indexpatternspec.runtimefieldmap.md) + +## IndexPatternSpec.runtimeFieldMap property + +Signature: + +```typescript +runtimeFieldMap?: Record; +``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpattern.addruntimefield.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpattern.addruntimefield.md new file mode 100644 index 00000000000000..ebd7f46d3598e9 --- /dev/null +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpattern.addruntimefield.md @@ -0,0 +1,25 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [IndexPattern](./kibana-plugin-plugins-data-server.indexpattern.md) > [addRuntimeField](./kibana-plugin-plugins-data-server.indexpattern.addruntimefield.md) + +## IndexPattern.addRuntimeField() method + +Add a runtime field - Appended to existing mapped field or a new field is created as appropriate + +Signature: + +```typescript +addRuntimeField(name: string, runtimeField: RuntimeField): void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| name | string | | +| runtimeField | RuntimeField | | + +Returns: + +`void` + diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpattern.getassavedobjectbody.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpattern.getassavedobjectbody.md index 7d70af4b535fea..668d563ff04c0e 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpattern.getassavedobjectbody.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpattern.getassavedobjectbody.md @@ -20,6 +20,7 @@ getAsSavedObjectBody(): { type: string | undefined; typeMeta: string | undefined; allowNoIndex: true | undefined; + runtimeFieldMap: string | undefined; }; ``` Returns: @@ -35,5 +36,6 @@ getAsSavedObjectBody(): { type: string | undefined; typeMeta: string | undefined; allowNoIndex: true | undefined; + runtimeFieldMap: string | undefined; }` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpattern.getcomputedfields.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpattern.getcomputedfields.md index eab6ae9bf90331..0030adf1261e47 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpattern.getcomputedfields.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpattern.getcomputedfields.md @@ -14,6 +14,7 @@ getComputedFields(): { field: any; format: string; }[]; + runtimeFields: Record; }; ``` Returns: @@ -25,5 +26,6 @@ getComputedFields(): { field: any; format: string; }[]; + runtimeFields: Record; }` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpattern.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpattern.md index 70c37ba1b39262..97d1cd91152623 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpattern.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpattern.md @@ -45,6 +45,7 @@ export declare class IndexPattern implements IIndexPattern | Method | Modifiers | Description | | --- | --- | --- | +| [addRuntimeField(name, runtimeField)](./kibana-plugin-plugins-data-server.indexpattern.addruntimefield.md) | | Add a runtime field - Appended to existing mapped field or a new field is created as appropriate | | [addScriptedField(name, script, fieldType)](./kibana-plugin-plugins-data-server.indexpattern.addscriptedfield.md) | | Add scripted field to field list | | [getAggregationRestrictions()](./kibana-plugin-plugins-data-server.indexpattern.getaggregationrestrictions.md) | | | | [getAsSavedObjectBody()](./kibana-plugin-plugins-data-server.indexpattern.getassavedobjectbody.md) | | Returns index pattern as saved object body for saving | @@ -58,6 +59,7 @@ export declare class IndexPattern implements IIndexPattern | [getTimeField()](./kibana-plugin-plugins-data-server.indexpattern.gettimefield.md) | | | | [isTimeBased()](./kibana-plugin-plugins-data-server.indexpattern.istimebased.md) | | | | [isTimeNanosBased()](./kibana-plugin-plugins-data-server.indexpattern.istimenanosbased.md) | | | +| [removeRuntimeField(name)](./kibana-plugin-plugins-data-server.indexpattern.removeruntimefield.md) | | Remove a runtime field - removed from mapped field or removed unmapped field as appropriate | | [removeScriptedField(fieldName)](./kibana-plugin-plugins-data-server.indexpattern.removescriptedfield.md) | | Remove scripted field from field list | | [setFieldAttrs(fieldName, attrName, value)](./kibana-plugin-plugins-data-server.indexpattern.setfieldattrs.md) | | | | [setFieldCount(fieldName, count)](./kibana-plugin-plugins-data-server.indexpattern.setfieldcount.md) | | | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpattern.removeruntimefield.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpattern.removeruntimefield.md new file mode 100644 index 00000000000000..da8e7e40a7fac2 --- /dev/null +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpattern.removeruntimefield.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [IndexPattern](./kibana-plugin-plugins-data-server.indexpattern.md) > [removeRuntimeField](./kibana-plugin-plugins-data-server.indexpattern.removeruntimefield.md) + +## IndexPattern.removeRuntimeField() method + +Remove a runtime field - removed from mapped field or removed unmapped field as appropriate + +Signature: + +```typescript +removeRuntimeField(name: string): void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| name | string | | + +Returns: + +`void` + diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternattributes.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternattributes.md index bfc7f65425f9c4..20af97ecc8761f 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternattributes.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternattributes.md @@ -21,6 +21,7 @@ export interface IndexPatternAttributes | [fieldFormatMap](./kibana-plugin-plugins-data-server.indexpatternattributes.fieldformatmap.md) | string | | | [fields](./kibana-plugin-plugins-data-server.indexpatternattributes.fields.md) | string | | | [intervalName](./kibana-plugin-plugins-data-server.indexpatternattributes.intervalname.md) | string | | +| [runtimeFieldMap](./kibana-plugin-plugins-data-server.indexpatternattributes.runtimefieldmap.md) | string | | | [sourceFilters](./kibana-plugin-plugins-data-server.indexpatternattributes.sourcefilters.md) | string | | | [timeFieldName](./kibana-plugin-plugins-data-server.indexpatternattributes.timefieldname.md) | string | | | [title](./kibana-plugin-plugins-data-server.indexpatternattributes.title.md) | string | | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternattributes.runtimefieldmap.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternattributes.runtimefieldmap.md new file mode 100644 index 00000000000000..1e0dff2ad0e46b --- /dev/null +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternattributes.runtimefieldmap.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [IndexPatternAttributes](./kibana-plugin-plugins-data-server.indexpatternattributes.md) > [runtimeFieldMap](./kibana-plugin-plugins-data-server.indexpatternattributes.runtimefieldmap.md) + +## IndexPatternAttributes.runtimeFieldMap property + +Signature: + +```typescript +runtimeFieldMap?: string; +``` diff --git a/src/plugins/data/common/index_patterns/fields/__snapshots__/index_pattern_field.test.ts.snap b/src/plugins/data/common/index_patterns/fields/__snapshots__/index_pattern_field.test.ts.snap index 3e09fa449a1aa3..4ef61ec0f25571 100644 --- a/src/plugins/data/common/index_patterns/fields/__snapshots__/index_pattern_field.test.ts.snap +++ b/src/plugins/data/common/index_patterns/fields/__snapshots__/index_pattern_field.test.ts.snap @@ -57,9 +57,16 @@ Object { "pattern": "$0,0.[00]", }, }, + "isMapped": undefined, "lang": "lang", "name": "name", "readFromDocValues": false, + "runtimeField": Object { + "script": Object { + "source": "emit('hello world')", + }, + "type": "keyword", + }, "script": "script", "scripted": true, "searchable": true, diff --git a/src/plugins/data/common/index_patterns/fields/index_pattern_field.test.ts b/src/plugins/data/common/index_patterns/fields/index_pattern_field.test.ts index bce75f9932479a..8a73abb3c7d830 100644 --- a/src/plugins/data/common/index_patterns/fields/index_pattern_field.test.ts +++ b/src/plugins/data/common/index_patterns/fields/index_pattern_field.test.ts @@ -9,7 +9,7 @@ import { IndexPatternField } from './index_pattern_field'; import { IndexPattern } from '../index_patterns'; import { KBN_FIELD_TYPES, FieldFormat } from '../../../common'; -import { FieldSpec } from '../types'; +import { FieldSpec, RuntimeField } from '../types'; describe('Field', function () { function flatten(obj: Record) { @@ -42,6 +42,12 @@ describe('Field', function () { } as unknown) as IndexPattern, $$spec: ({} as unknown) as FieldSpec, conflictDescriptions: { a: ['b', 'c'], d: ['e'] }, + runtimeField: { + type: 'keyword' as RuntimeField['type'], + script: { + source: "emit('hello world')", + }, + }, }; it('the correct properties are writable', () => { diff --git a/src/plugins/data/common/index_patterns/fields/index_pattern_field.ts b/src/plugins/data/common/index_patterns/fields/index_pattern_field.ts index 540563c3a8cfc7..ed6c4bd40d5616 100644 --- a/src/plugins/data/common/index_patterns/fields/index_pattern_field.ts +++ b/src/plugins/data/common/index_patterns/fields/index_pattern_field.ts @@ -6,9 +6,10 @@ * Public License, v 1. */ +import type { RuntimeField } from '../types'; import { KbnFieldType, getKbnFieldType } from '../../kbn_field_types'; import { KBN_FIELD_TYPES } from '../../kbn_field_types/types'; -import { IFieldType } from './types'; +import type { IFieldType } from './types'; import { FieldSpec, IndexPattern } from '../..'; import { shortenDottedString } from '../../utils'; @@ -35,6 +36,14 @@ export class IndexPatternField implements IFieldType { this.spec.count = count; } + public get runtimeField() { + return this.spec.runtimeField; + } + + public set runtimeField(runtimeField: RuntimeField | undefined) { + this.spec.runtimeField = runtimeField; + } + /** * Script field code */ @@ -117,6 +126,13 @@ export class IndexPatternField implements IFieldType { return this.spec.subType; } + /** + * Is the field part of the index mapping? + */ + public get isMapped() { + return this.spec.isMapped; + } + // not writable, not serialized public get sortable() { return ( @@ -181,6 +197,8 @@ export class IndexPatternField implements IFieldType { format: getFormatterForField ? getFormatterForField(this).toJSON() : undefined, customLabel: this.customLabel, shortDotsEnable: this.spec.shortDotsEnable, + runtimeField: this.runtimeField, + isMapped: this.isMapped, }; } } diff --git a/src/plugins/data/common/index_patterns/index_patterns/__snapshots__/index_pattern.test.ts.snap b/src/plugins/data/common/index_patterns/index_patterns/__snapshots__/index_pattern.test.ts.snap index 76de2b2662bb02..4aadddfad3b970 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/__snapshots__/index_pattern.test.ts.snap +++ b/src/plugins/data/common/index_patterns/index_patterns/__snapshots__/index_pattern.test.ts.snap @@ -20,9 +20,11 @@ Object { "pattern": "$0,0.[00]", }, }, + "isMapped": true, "lang": undefined, "name": "@tags", "readFromDocValues": true, + "runtimeField": undefined, "script": undefined, "scripted": false, "searchable": true, @@ -44,9 +46,11 @@ Object { "pattern": "$0,0.[00]", }, }, + "isMapped": true, "lang": undefined, "name": "@timestamp", "readFromDocValues": true, + "runtimeField": undefined, "script": undefined, "scripted": false, "searchable": true, @@ -68,9 +72,11 @@ Object { "pattern": "$0,0.[00]", }, }, + "isMapped": true, "lang": undefined, "name": "_id", "readFromDocValues": false, + "runtimeField": undefined, "script": undefined, "scripted": false, "searchable": true, @@ -92,9 +98,11 @@ Object { "pattern": "$0,0.[00]", }, }, + "isMapped": true, "lang": undefined, "name": "_source", "readFromDocValues": false, + "runtimeField": undefined, "script": undefined, "scripted": false, "searchable": true, @@ -116,9 +124,11 @@ Object { "pattern": "$0,0.[00]", }, }, + "isMapped": true, "lang": undefined, "name": "_type", "readFromDocValues": false, + "runtimeField": undefined, "script": undefined, "scripted": false, "searchable": true, @@ -140,9 +150,11 @@ Object { "pattern": "$0,0.[00]", }, }, + "isMapped": true, "lang": undefined, "name": "area", "readFromDocValues": false, + "runtimeField": undefined, "script": undefined, "scripted": false, "searchable": true, @@ -164,9 +176,11 @@ Object { "pattern": "$0,0.[00]", }, }, + "isMapped": true, "lang": undefined, "name": "bytes", "readFromDocValues": true, + "runtimeField": undefined, "script": undefined, "scripted": false, "searchable": true, @@ -188,9 +202,11 @@ Object { "pattern": "$0,0.[00]", }, }, + "isMapped": true, "lang": undefined, "name": "custom_user_field", "readFromDocValues": true, + "runtimeField": undefined, "script": undefined, "scripted": false, "searchable": true, @@ -212,9 +228,11 @@ Object { "pattern": "$0,0.[00]", }, }, + "isMapped": true, "lang": undefined, "name": "extension", "readFromDocValues": false, + "runtimeField": undefined, "script": undefined, "scripted": false, "searchable": true, @@ -236,9 +254,11 @@ Object { "pattern": "$0,0.[00]", }, }, + "isMapped": true, "lang": undefined, "name": "extension.keyword", "readFromDocValues": true, + "runtimeField": undefined, "script": undefined, "scripted": false, "searchable": true, @@ -264,9 +284,11 @@ Object { "pattern": "$0,0.[00]", }, }, + "isMapped": true, "lang": undefined, "name": "geo.coordinates", "readFromDocValues": true, + "runtimeField": undefined, "script": undefined, "scripted": false, "searchable": true, @@ -288,9 +310,11 @@ Object { "pattern": "$0,0.[00]", }, }, + "isMapped": true, "lang": undefined, "name": "geo.src", "readFromDocValues": true, + "runtimeField": undefined, "script": undefined, "scripted": false, "searchable": true, @@ -312,9 +336,11 @@ Object { "pattern": "$0,0.[00]", }, }, + "isMapped": true, "lang": undefined, "name": "hashed", "readFromDocValues": false, + "runtimeField": undefined, "script": undefined, "scripted": false, "searchable": true, @@ -336,9 +362,11 @@ Object { "pattern": "$0,0.[00]", }, }, + "isMapped": true, "lang": undefined, "name": "ip", "readFromDocValues": true, + "runtimeField": undefined, "script": undefined, "scripted": false, "searchable": true, @@ -360,9 +388,11 @@ Object { "pattern": "$0,0.[00]", }, }, + "isMapped": true, "lang": undefined, "name": "machine.os", "readFromDocValues": false, + "runtimeField": undefined, "script": undefined, "scripted": false, "searchable": true, @@ -384,9 +414,11 @@ Object { "pattern": "$0,0.[00]", }, }, + "isMapped": true, "lang": undefined, "name": "machine.os.raw", "readFromDocValues": true, + "runtimeField": undefined, "script": undefined, "scripted": false, "searchable": true, @@ -412,9 +444,11 @@ Object { "pattern": "$0,0.[00]", }, }, + "isMapped": true, "lang": undefined, "name": "non-filterable", "readFromDocValues": false, + "runtimeField": undefined, "script": undefined, "scripted": false, "searchable": false, @@ -436,9 +470,11 @@ Object { "pattern": "$0,0.[00]", }, }, + "isMapped": true, "lang": undefined, "name": "non-sortable", "readFromDocValues": false, + "runtimeField": undefined, "script": undefined, "scripted": false, "searchable": false, @@ -460,9 +496,11 @@ Object { "pattern": "$0,0.[00]", }, }, + "isMapped": true, "lang": undefined, "name": "phpmemory", "readFromDocValues": true, + "runtimeField": undefined, "script": undefined, "scripted": false, "searchable": true, @@ -484,9 +522,11 @@ Object { "pattern": "$0,0.[00]", }, }, + "isMapped": true, "lang": undefined, "name": "point", "readFromDocValues": true, + "runtimeField": undefined, "script": undefined, "scripted": false, "searchable": true, @@ -508,9 +548,11 @@ Object { "pattern": "$0,0.[00]", }, }, + "isMapped": true, "lang": undefined, "name": "request_body", "readFromDocValues": true, + "runtimeField": undefined, "script": undefined, "scripted": false, "searchable": true, @@ -518,6 +560,35 @@ Object { "subType": undefined, "type": "attachment", }, + "runtime_field": Object { + "aggregatable": false, + "conflictDescriptions": undefined, + "count": 0, + "customLabel": undefined, + "esTypes": undefined, + "format": Object { + "id": "number", + "params": Object { + "pattern": "$0,0.[00]", + }, + }, + "isMapped": undefined, + "lang": undefined, + "name": "runtime_field", + "readFromDocValues": false, + "runtimeField": Object { + "script": Object { + "source": "emit('hello world')", + }, + "type": "keyword", + }, + "script": undefined, + "scripted": false, + "searchable": false, + "shortDotsEnable": false, + "subType": undefined, + "type": undefined, + }, "script date": Object { "aggregatable": true, "conflictDescriptions": undefined, @@ -532,9 +603,11 @@ Object { "pattern": "$0,0.[00]", }, }, + "isMapped": false, "lang": "painless", "name": "script date", "readFromDocValues": false, + "runtimeField": undefined, "script": "1234", "scripted": true, "searchable": true, @@ -556,9 +629,11 @@ Object { "pattern": "$0,0.[00]", }, }, + "isMapped": false, "lang": "expression", "name": "script murmur3", "readFromDocValues": false, + "runtimeField": undefined, "script": "1234", "scripted": true, "searchable": true, @@ -580,9 +655,11 @@ Object { "pattern": "$0,0.[00]", }, }, + "isMapped": false, "lang": "expression", "name": "script number", "readFromDocValues": false, + "runtimeField": undefined, "script": "1234", "scripted": true, "searchable": true, @@ -604,9 +681,11 @@ Object { "pattern": "$0,0.[00]", }, }, + "isMapped": false, "lang": "expression", "name": "script string", "readFromDocValues": false, + "runtimeField": undefined, "script": "'i am a string'", "scripted": true, "searchable": true, @@ -628,9 +707,11 @@ Object { "pattern": "$0,0.[00]", }, }, + "isMapped": true, "lang": undefined, "name": "ssl", "readFromDocValues": true, + "runtimeField": undefined, "script": undefined, "scripted": false, "searchable": true, @@ -652,9 +733,11 @@ Object { "pattern": "$0,0.[00]", }, }, + "isMapped": true, "lang": undefined, "name": "time", "readFromDocValues": true, + "runtimeField": undefined, "script": undefined, "scripted": false, "searchable": true, @@ -676,9 +759,11 @@ Object { "pattern": "$0,0.[00]", }, }, + "isMapped": true, "lang": undefined, "name": "utc_time", "readFromDocValues": true, + "runtimeField": undefined, "script": undefined, "scripted": false, "searchable": true, @@ -689,6 +774,14 @@ Object { }, "id": "test-pattern", "intervalName": undefined, + "runtimeFieldMap": Object { + "runtime_field": Object { + "script": Object { + "source": "emit('hello world')", + }, + "type": "keyword", + }, + }, "sourceFilters": undefined, "timeFieldName": "timestamp", "title": "title", diff --git a/src/plugins/data/common/index_patterns/index_patterns/__snapshots__/index_patterns.test.ts.snap b/src/plugins/data/common/index_patterns/index_patterns/__snapshots__/index_patterns.test.ts.snap index bad74430b89668..d6da4adac81a4c 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/__snapshots__/index_patterns.test.ts.snap +++ b/src/plugins/data/common/index_patterns/index_patterns/__snapshots__/index_patterns.test.ts.snap @@ -10,6 +10,7 @@ Object { "fields": Object {}, "id": "id", "intervalName": undefined, + "runtimeFieldMap": Object {}, "sourceFilters": Array [ Object { "value": "item1", diff --git a/src/plugins/data/common/index_patterns/index_patterns/fixtures/logstash_fields.js b/src/plugins/data/common/index_patterns/index_patterns/fixtures/logstash_fields.js index 3e81b9234ee644..2bcb8df34cf026 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/fixtures/logstash_fields.js +++ b/src/plugins/data/common/index_patterns/index_patterns/fixtures/logstash_fields.js @@ -68,6 +68,7 @@ function stubbedLogstashFields() { lang, scripted, subType, + isMapped: !scripted, }; }); } diff --git a/src/plugins/data/common/index_patterns/index_patterns/index_pattern.test.ts b/src/plugins/data/common/index_patterns/index_patterns/index_pattern.test.ts index bb7ed17f9e6086..4f6e83460aecf2 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/index_pattern.test.ts +++ b/src/plugins/data/common/index_patterns/index_patterns/index_pattern.test.ts @@ -18,9 +18,27 @@ import { IndexPatternField } from '../fields'; import { fieldFormatsMock } from '../../field_formats/mocks'; import { FieldFormat } from '../..'; +import { RuntimeField } from '../types'; class MockFieldFormatter {} +const runtimeFieldScript = { + type: 'keyword' as RuntimeField['type'], + script: { + source: "emit('hello world')", + }, +}; + +const runtimeFieldMap = { + runtime_field: runtimeFieldScript, +}; + +const runtimeField = { + name: 'runtime_field', + runtimeField: runtimeFieldScript, + scripted: false, +}; + fieldFormatsMock.getInstance = jest.fn().mockImplementation(() => new MockFieldFormatter()) as any; // helper function to create index patterns @@ -32,7 +50,15 @@ function create(id: string) { } = stubbedSavedObjectIndexPattern(id); return new IndexPattern({ - spec: { id, type, version, timeFieldName, fields, title }, + spec: { + id, + type, + version, + timeFieldName, + fields: { ...fields, runtime_field: runtimeField }, + title, + runtimeFieldMap, + }, fieldFormats: fieldFormatsMock, shortDotsEnable: false, metaFields: [], @@ -53,6 +79,10 @@ describe('IndexPattern', () => { expect(indexPattern).toHaveProperty('getNonScriptedFields'); expect(indexPattern).toHaveProperty('addScriptedField'); expect(indexPattern).toHaveProperty('removeScriptedField'); + expect(indexPattern).toHaveProperty('addScriptedField'); + expect(indexPattern).toHaveProperty('removeScriptedField'); + expect(indexPattern).toHaveProperty('addRuntimeField'); + expect(indexPattern).toHaveProperty('removeRuntimeField'); // properties expect(indexPattern).toHaveProperty('fields'); @@ -65,6 +95,7 @@ describe('IndexPattern', () => { expect(indexPattern.fields[0]).toHaveProperty('filterable'); expect(indexPattern.fields[0]).toHaveProperty('sortable'); expect(indexPattern.fields[0]).toHaveProperty('scripted'); + expect(indexPattern.fields[0]).toHaveProperty('isMapped'); }); }); @@ -98,6 +129,12 @@ describe('IndexPattern', () => { expect(docValueFieldNames).toContain('utc_time'); }); + test('should return runtimeField', () => { + expect(indexPattern.getComputedFields().runtimeFields).toEqual({ + runtime_field: runtimeFieldScript, + }); + }); + test('should request date field doc values in date_time format', () => { const { docvalueFields } = indexPattern.getComputedFields(); const timestampField = docvalueFields.find((field) => field.field === '@timestamp'); @@ -117,6 +154,7 @@ describe('IndexPattern', () => { const notScriptedNames = mockLogStashFields() .filter((item: IndexPatternField) => item.scripted === false) .map((item: IndexPatternField) => item.name); + notScriptedNames.push('runtime_field'); const respNames = map(indexPattern.getNonScriptedFields(), 'name'); expect(respNames).toEqual(notScriptedNames); @@ -185,6 +223,52 @@ describe('IndexPattern', () => { }); }); + describe('addRuntimeField and removeRuntimeField', () => { + const runtime = { + type: 'keyword' as RuntimeField['type'], + script: { + source: "emit('hello world');", + }, + }; + + beforeEach(() => { + const formatter = { + toJSON: () => ({ id: 'bytes' }), + } as FieldFormat; + indexPattern.getFormatterForField = () => formatter; + }); + + test('add and remove runtime field to existing field', () => { + indexPattern.addRuntimeField('@tags', runtime); + expect(indexPattern.toSpec().runtimeFieldMap).toEqual({ + '@tags': runtime, + runtime_field: runtimeField.runtimeField, + }); + expect(indexPattern.toSpec()!.fields!['@tags'].runtimeField).toEqual(runtime); + + indexPattern.removeRuntimeField('@tags'); + expect(indexPattern.toSpec().runtimeFieldMap).toEqual({ + runtime_field: runtimeField.runtimeField, + }); + expect(indexPattern.toSpec()!.fields!['@tags'].runtimeField).toBeUndefined(); + }); + + test('add and remove runtime field as new field', () => { + indexPattern.addRuntimeField('new_field', runtime); + expect(indexPattern.toSpec().runtimeFieldMap).toEqual({ + runtime_field: runtimeField.runtimeField, + new_field: runtime, + }); + expect(indexPattern.toSpec()!.fields!.new_field.runtimeField).toEqual(runtime); + + indexPattern.removeRuntimeField('new_field'); + expect(indexPattern.toSpec().runtimeFieldMap).toEqual({ + runtime_field: runtimeField.runtimeField, + }); + expect(indexPattern.toSpec()!.fields!.new_field).toBeUndefined(); + }); + }); + describe('getFormatterForField', () => { test('should return the default one for empty objects', () => { indexPattern.setFieldFormat('scriptedFieldWithEmptyFormatter', {}); diff --git a/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts b/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts index 452c663d96716c..144d38fe15909f 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts +++ b/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts @@ -8,6 +8,7 @@ import _, { each, reject } from 'lodash'; import { FieldAttrs, FieldAttrSet } from '../..'; +import type { RuntimeField } from '../types'; import { DuplicateField } from '../../../../kibana_utils/common'; import { ES_FIELD_TYPES, KBN_FIELD_TYPES, IIndexPattern, IFieldType } from '../../../common'; @@ -17,6 +18,7 @@ import { flattenHitWrapper } from './flatten_hit'; import { FieldFormatsStartCommon, FieldFormat } from '../../field_formats'; import { IndexPatternSpec, TypeMeta, SourceFilter, IndexPatternFieldMap } from '../types'; import { SerializedFieldFormat } from '../../../../expressions/common'; +import { castEsToKbnFieldTypeName } from '../../kbn_field_types'; interface IndexPatternDeps { spec?: IndexPatternSpec; @@ -74,6 +76,8 @@ export class IndexPattern implements IIndexPattern { private shortDotsEnable: boolean = false; private fieldFormats: FieldFormatsStartCommon; private fieldAttrs: FieldAttrs; + private runtimeFieldMap: Record; + /** * prevents errors when index pattern exists before indices */ @@ -115,6 +119,7 @@ export class IndexPattern implements IIndexPattern { this.fieldAttrs = spec.fieldAttrs || {}; this.intervalName = spec.intervalName; this.allowNoIndex = spec.allowNoIndex || false; + this.runtimeFieldMap = spec.runtimeFieldMap || {}; } /** @@ -160,7 +165,8 @@ export class IndexPattern implements IIndexPattern { return { storedFields: ['*'], scriptFields, - docvalueFields: [], + docvalueFields: [] as Array<{ field: string; format: string }>, + runtimeFields: {}, }; } @@ -192,6 +198,7 @@ export class IndexPattern implements IIndexPattern { storedFields: ['*'], scriptFields, docvalueFields, + runtimeFields: this.runtimeFieldMap, }; } @@ -210,6 +217,7 @@ export class IndexPattern implements IIndexPattern { typeMeta: this.typeMeta, type: this.type, fieldFormats: this.fieldFormatMap, + runtimeFieldMap: this.runtimeFieldMap, fieldAttrs: this.fieldAttrs, intervalName: this.intervalName, allowNoIndex: this.allowNoIndex, @@ -305,6 +313,7 @@ export class IndexPattern implements IIndexPattern { ? undefined : JSON.stringify(this.fieldFormatMap); const fieldAttrs = this.getFieldAttrs(); + const runtimeFieldMap = this.runtimeFieldMap; return { fieldAttrs: fieldAttrs ? JSON.stringify(fieldAttrs) : undefined, @@ -319,6 +328,7 @@ export class IndexPattern implements IIndexPattern { type: this.type, typeMeta: this.typeMeta ? JSON.stringify(this.typeMeta) : undefined, allowNoIndex: this.allowNoIndex ? this.allowNoIndex : undefined, + runtimeFieldMap: runtimeFieldMap ? JSON.stringify(runtimeFieldMap) : undefined, }; } @@ -340,6 +350,51 @@ export class IndexPattern implements IIndexPattern { ); } + /** + * Add a runtime field - Appended to existing mapped field or a new field is + * created as appropriate + * @param name Field name + * @param runtimeField Runtime field definition + */ + + addRuntimeField(name: string, runtimeField: RuntimeField) { + const existingField = this.getFieldByName(name); + if (existingField) { + existingField.runtimeField = runtimeField; + } else { + this.fields.add({ + name, + runtimeField, + type: castEsToKbnFieldTypeName(runtimeField.type), + aggregatable: true, + searchable: true, + count: 0, + readFromDocValues: false, + }); + } + this.runtimeFieldMap[name] = runtimeField; + } + + /** + * Remove a runtime field - removed from mapped field or removed unmapped + * field as appropriate + * @param name Field name + */ + + removeRuntimeField(name: string) { + const existingField = this.getFieldByName(name); + if (existingField) { + if (existingField.isMapped) { + // mapped field, remove runtimeField def + existingField.runtimeField = undefined; + } else { + // runtimeField only + this.fields.remove(existingField); + } + } + delete this.runtimeFieldMap[name]; + } + /** * Get formatter for a given field name. Return undefined if none exists * @param field diff --git a/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts index 80cb8a55fa0a02..60436da530b636 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts +++ b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts @@ -11,6 +11,7 @@ import { PublicMethodsOf } from '@kbn/utility-types'; import { SavedObjectsClientCommon } from '../..'; import { createIndexPatternCache } from '.'; +import type { RuntimeField } from '../types'; import { IndexPattern } from './index_pattern'; import { createEnsureDefaultIndexPattern, @@ -34,6 +35,7 @@ import { SavedObjectNotFound } from '../../../../kibana_utils/common'; import { IndexPatternMissingIndices } from '../lib'; import { findByTitle } from '../utils'; import { DuplicateIndexPatternError } from '../errors'; +import { castEsToKbnFieldTypeName } from '../../kbn_field_types'; const MAX_ATTEMPTS_TO_RESOLVE_CONFLICTS = 3; const savedObjectType = 'index-pattern'; @@ -247,7 +249,8 @@ export class IndexPatternsService { */ refreshFields = async (indexPattern: IndexPattern) => { try { - const fields = await this.getFieldsForIndexPattern(indexPattern); + const fields = (await this.getFieldsForIndexPattern(indexPattern)) as FieldSpec[]; + fields.forEach((field) => (field.isMapped = true)); const scripted = indexPattern.getScriptedFields().map((field) => field.spec); const fieldAttrs = indexPattern.getFieldAttrs(); const fieldsWithSavedAttrs = Object.values( @@ -288,6 +291,7 @@ export class IndexPatternsService { try { let updatedFieldList: FieldSpec[]; const newFields = (await this.getFieldsForWildcard(options)) as FieldSpec[]; + newFields.forEach((field) => (field.isMapped = true)); // If allowNoIndex, only update field list if field caps finds fields. To support // beats creating index pattern and dashboard before docs @@ -347,6 +351,7 @@ export class IndexPatternsService { fields, sourceFilters, fieldFormatMap, + runtimeFieldMap, typeMeta, type, fieldAttrs, @@ -359,6 +364,9 @@ export class IndexPatternsService { const parsedFieldFormatMap = fieldFormatMap ? JSON.parse(fieldFormatMap) : {}; const parsedFields: FieldSpec[] = fields ? JSON.parse(fields) : []; const parsedFieldAttrs: FieldAttrs = fieldAttrs ? JSON.parse(fieldAttrs) : {}; + const parsedRuntimeFieldMap: Record = runtimeFieldMap + ? JSON.parse(runtimeFieldMap) + : {}; return { id, @@ -373,6 +381,7 @@ export class IndexPatternsService { fieldFormats: parsedFieldFormatMap, fieldAttrs: parsedFieldAttrs, allowNoIndex, + runtimeFieldMap: parsedRuntimeFieldMap, }; }; @@ -387,7 +396,7 @@ export class IndexPatternsService { } const spec = this.savedObjectToSpec(savedObject); - const { title, type, typeMeta } = spec; + const { title, type, typeMeta, runtimeFieldMap } = spec; spec.fieldAttrs = savedObject.attributes.fieldAttrs ? JSON.parse(savedObject.attributes.fieldAttrs) : {}; @@ -406,6 +415,22 @@ export class IndexPatternsService { }, spec.fieldAttrs ); + // APPLY RUNTIME FIELDS + for (const [key, value] of Object.entries(runtimeFieldMap || {})) { + if (spec.fields[key]) { + spec.fields[key].runtimeField = value; + } else { + spec.fields[key] = { + name: key, + type: castEsToKbnFieldTypeName(value.type), + runtimeField: value, + aggregatable: true, + searchable: true, + count: 0, + readFromDocValues: false, + }; + } + } } catch (err) { if (err instanceof IndexPatternMissingIndices) { this.onNotification({ diff --git a/src/plugins/data/common/index_patterns/types.ts b/src/plugins/data/common/index_patterns/types.ts index 9f9a26604a0e56..467b5125f03271 100644 --- a/src/plugins/data/common/index_patterns/types.ts +++ b/src/plugins/data/common/index_patterns/types.ts @@ -14,6 +14,14 @@ import { SerializedFieldFormat } from '../../../expressions/common'; import { KBN_FIELD_TYPES, IndexPatternField, FieldFormat } from '..'; export type FieldFormatMap = Record; +const RUNTIME_FIELD_TYPES = ['keyword', 'long', 'double', 'date', 'ip', 'boolean'] as const; +type RuntimeType = typeof RUNTIME_FIELD_TYPES[number]; +export interface RuntimeField { + type: RuntimeType; + script: { + source: string; + }; +} /** * IIndexPattern allows for an IndexPattern OR an index pattern saved object @@ -51,6 +59,7 @@ export interface IndexPatternAttributes { sourceFilters?: string; fieldFormatMap?: string; fieldAttrs?: string; + runtimeFieldMap?: string; /** * prevents errors when index pattern exists before indices */ @@ -199,8 +208,10 @@ export interface FieldSpec { subType?: IFieldSubType; indexed?: boolean; customLabel?: string; + runtimeField?: RuntimeField; // not persisted shortDotsEnable?: boolean; + isMapped?: boolean; } export type IndexPatternFieldMap = Record; @@ -230,6 +241,7 @@ export interface IndexPatternSpec { typeMeta?: TypeMeta; type?: string; fieldFormats?: Record; + runtimeFieldMap?: Record; fieldAttrs?: FieldAttrs; allowNoIndex?: boolean; } diff --git a/src/plugins/data/common/search/search_source/search_source.test.ts b/src/plugins/data/common/search/search_source/search_source.test.ts index 0b9c60e94a1985..6d7654c6659f23 100644 --- a/src/plugins/data/common/search/search_source/search_source.test.ts +++ b/src/plugins/data/common/search/search_source/search_source.test.ts @@ -20,6 +20,7 @@ const getComputedFields = () => ({ storedFields: [], scriptFields: {}, docvalueFields: [], + runtimeFields: {}, }); const mockSource = { excludes: ['foo-*'] }; @@ -37,6 +38,13 @@ const indexPattern2 = ({ getSourceFiltering: () => mockSource2, } as unknown) as IndexPattern; +const runtimeFieldDef = { + type: 'keyword', + script: { + source: "emit('hello world')", + }, +}; + describe('SearchSource', () => { let mockSearchMethod: any; let searchSourceDependencies: SearchSourceDependencies; @@ -82,12 +90,14 @@ describe('SearchSource', () => { describe('computed fields handling', () => { test('still provides computed fields when no fields are specified', async () => { + const runtimeFields = { runtime_field: runtimeFieldDef }; searchSource.setField('index', ({ ...indexPattern, getComputedFields: () => ({ storedFields: ['hello'], scriptFields: { world: {} }, docvalueFields: ['@timestamp'], + runtimeFields, }), } as unknown) as IndexPattern); @@ -95,6 +105,7 @@ describe('SearchSource', () => { expect(request.stored_fields).toEqual(['hello']); expect(request.script_fields).toEqual({ world: {} }); expect(request.fields).toEqual(['@timestamp']); + expect(request.runtime_mappings).toEqual(runtimeFields); }); test('never includes docvalue_fields', async () => { @@ -390,15 +401,23 @@ describe('SearchSource', () => { }); test('filters request when a specific list of fields is provided with fieldsFromSource', async () => { + const runtimeFields = { runtime_field: runtimeFieldDef, runtime_field_b: runtimeFieldDef }; searchSource.setField('index', ({ ...indexPattern, getComputedFields: () => ({ storedFields: ['*'], scriptFields: { hello: {}, world: {} }, docvalueFields: ['@timestamp', 'date'], + runtimeFields, }), } as unknown) as IndexPattern); - searchSource.setField('fieldsFromSource', ['hello', '@timestamp', 'foo-a', 'bar']); + searchSource.setField('fieldsFromSource', [ + 'hello', + '@timestamp', + 'foo-a', + 'bar', + 'runtime_field', + ]); const request = await searchSource.getSearchRequestBody(); expect(request._source).toEqual({ @@ -407,6 +426,7 @@ describe('SearchSource', () => { expect(request.fields).toEqual(['@timestamp']); expect(request.script_fields).toEqual({ hello: {} }); expect(request.stored_fields).toEqual(['@timestamp', 'bar']); + expect(request.runtime_mappings).toEqual({ runtime_field: runtimeFieldDef }); }); test('filters request when a specific list of fields is provided with fieldsFromSource or fields', async () => { diff --git a/src/plugins/data/common/search/search_source/search_source.ts b/src/plugins/data/common/search/search_source/search_source.ts index 0f0688c9fc11f9..554e8385881f23 100644 --- a/src/plugins/data/common/search/search_source/search_source.ts +++ b/src/plugins/data/common/search/search_source/search_source.ts @@ -461,12 +461,13 @@ export class SearchSource { searchRequest.indexType = this.getIndexType(index); // get some special field types from the index pattern - const { docvalueFields, scriptFields, storedFields } = index + const { docvalueFields, scriptFields, storedFields, runtimeFields } = index ? index.getComputedFields() : { docvalueFields: [], scriptFields: {}, storedFields: ['*'], + runtimeFields: {}, }; const fieldListProvided = !!body.fields; @@ -481,6 +482,7 @@ export class SearchSource { ...scriptFields, }; body.stored_fields = storedFields; + body.runtime_mappings = runtimeFields || {}; // apply source filters from index pattern if specified by the user let filteredDocvalueFields = docvalueFields; @@ -518,13 +520,18 @@ export class SearchSource { body.script_fields, Object.keys(body.script_fields).filter((f) => uniqFieldNames.includes(f)) ); + body.runtime_mappings = pick( + body.runtime_mappings, + Object.keys(body.runtime_mappings).filter((f) => uniqFieldNames.includes(f)) + ); } // request the remaining fields from stored_fields just in case, since the // fields API does not handle stored fields - const remainingFields = difference(uniqFieldNames, Object.keys(body.script_fields)).filter( - Boolean - ); + const remainingFields = difference(uniqFieldNames, [ + ...Object.keys(body.script_fields), + ...Object.keys(body.runtime_mappings), + ]).filter(Boolean); // only include unique values body.stored_fields = [...new Set(remainingFields)]; diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index d6bd896a584a46..28997de4517e79 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -1265,6 +1265,7 @@ export type IMetricAggType = MetricAggType; export class IndexPattern implements IIndexPattern { // Warning: (ae-forgotten-export) The symbol "IndexPatternDeps" needs to be exported by the entry point index.d.ts constructor({ spec, fieldFormats, shortDotsEnable, metaFields, }: IndexPatternDeps); + addRuntimeField(name: string, runtimeField: RuntimeField): void; addScriptedField(name: string, script: string, fieldType?: string): Promise; readonly allowNoIndex: boolean; // (undocumented) @@ -1304,6 +1305,7 @@ export class IndexPattern implements IIndexPattern { type: string | undefined; typeMeta: string | undefined; allowNoIndex: true | undefined; + runtimeFieldMap: string | undefined; }; // (undocumented) getComputedFields(): { @@ -1313,6 +1315,7 @@ export class IndexPattern implements IIndexPattern { field: any; format: string; }[]; + runtimeFields: Record; }; // (undocumented) getFieldAttrs: () => { @@ -1352,6 +1355,7 @@ export class IndexPattern implements IIndexPattern { isTimeNanosBased(): boolean; // (undocumented) metaFields: string[]; + removeRuntimeField(name: string): void; removeScriptedField(fieldName: string): void; resetOriginalSavedObjectBody: () => void; // (undocumented) @@ -1402,6 +1406,8 @@ export interface IndexPatternAttributes { // (undocumented) intervalName?: string; // (undocumented) + runtimeFieldMap?: string; + // (undocumented) sourceFilters?: string; // (undocumented) timeFieldName?: string; @@ -1435,12 +1441,16 @@ export class IndexPatternField implements IFieldType { get esTypes(): string[] | undefined; // (undocumented) get filterable(): boolean; + get isMapped(): boolean | undefined; get lang(): string | undefined; set lang(lang: string | undefined); // (undocumented) get name(): string; // (undocumented) get readFromDocValues(): boolean; + // (undocumented) + get runtimeField(): RuntimeField | undefined; + set runtimeField(runtimeField: RuntimeField | undefined); get script(): string | undefined; set script(script: string | undefined); // (undocumented) @@ -1537,6 +1547,8 @@ export interface IndexPatternSpec { // @deprecated (undocumented) intervalName?: string; // (undocumented) + runtimeFieldMap?: Record; + // (undocumented) sourceFilters?: SourceFilter[]; // (undocumented) timeFieldName?: string; @@ -2580,8 +2592,9 @@ export const UI_SETTINGS: { // src/plugins/data/common/es_query/filters/meta_filter.ts:43:3 - (ae-forgotten-export) The symbol "FilterMeta" needs to be exported by the entry point index.d.ts // src/plugins/data/common/es_query/filters/phrase_filter.ts:22:3 - (ae-forgotten-export) The symbol "PhraseFilterMeta" needs to be exported by the entry point index.d.ts // src/plugins/data/common/es_query/filters/phrases_filter.ts:20:3 - (ae-forgotten-export) The symbol "PhrasesFilterMeta" needs to be exported by the entry point index.d.ts -// src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:63:5 - (ae-forgotten-export) The symbol "FormatFieldFn" needs to be exported by the entry point index.d.ts -// src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:133:7 - (ae-forgotten-export) The symbol "FieldAttrSet" needs to be exported by the entry point index.d.ts +// src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:65:5 - (ae-forgotten-export) The symbol "FormatFieldFn" needs to be exported by the entry point index.d.ts +// src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:138:7 - (ae-forgotten-export) The symbol "FieldAttrSet" needs to be exported by the entry point index.d.ts +// src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:169:7 - (ae-forgotten-export) The symbol "RuntimeField" needs to be exported by the entry point index.d.ts // src/plugins/data/common/search/aggs/types.ts:139:51 - (ae-forgotten-export) The symbol "AggTypesRegistryStart" needs to be exported by the entry point index.d.ts // src/plugins/data/common/search/search_source/search_source.ts:186:7 - (ae-forgotten-export) The symbol "SearchFieldValue" needs to be exported by the entry point index.d.ts // src/plugins/data/public/field_formats/field_formats_service.ts:56:3 - (ae-forgotten-export) The symbol "FormatFactory" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index ef8015ecaca26d..6a96fd8209a8d6 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -705,6 +705,7 @@ export type IMetricAggType = MetricAggType; export class IndexPattern implements IIndexPattern { // Warning: (ae-forgotten-export) The symbol "IndexPatternDeps" needs to be exported by the entry point index.d.ts constructor({ spec, fieldFormats, shortDotsEnable, metaFields, }: IndexPatternDeps); + addRuntimeField(name: string, runtimeField: RuntimeField): void; addScriptedField(name: string, script: string, fieldType?: string): Promise; readonly allowNoIndex: boolean; // (undocumented) @@ -746,6 +747,7 @@ export class IndexPattern implements IIndexPattern { type: string | undefined; typeMeta: string | undefined; allowNoIndex: true | undefined; + runtimeFieldMap: string | undefined; }; // (undocumented) getComputedFields(): { @@ -755,6 +757,7 @@ export class IndexPattern implements IIndexPattern { field: any; format: string; }[]; + runtimeFields: Record; }; // (undocumented) getFieldAttrs: () => { @@ -796,6 +799,7 @@ export class IndexPattern implements IIndexPattern { isTimeNanosBased(): boolean; // (undocumented) metaFields: string[]; + removeRuntimeField(name: string): void; removeScriptedField(fieldName: string): void; resetOriginalSavedObjectBody: () => void; // (undocumented) @@ -838,6 +842,8 @@ export interface IndexPatternAttributes { // (undocumented) intervalName?: string; // (undocumented) + runtimeFieldMap?: string; + // (undocumented) sourceFilters?: string; // (undocumented) timeFieldName?: string; @@ -1394,9 +1400,10 @@ export function usageProvider(core: CoreSetup_2): SearchUsage; // // src/plugins/data/common/es_query/filters/meta_filter.ts:42:3 - (ae-forgotten-export) The symbol "FilterState" needs to be exported by the entry point index.d.ts // src/plugins/data/common/es_query/filters/meta_filter.ts:43:3 - (ae-forgotten-export) The symbol "FilterMeta" needs to be exported by the entry point index.d.ts -// src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:50:45 - (ae-forgotten-export) The symbol "IndexPatternFieldMap" needs to be exported by the entry point index.d.ts -// src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:63:5 - (ae-forgotten-export) The symbol "FormatFieldFn" needs to be exported by the entry point index.d.ts -// src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:133:7 - (ae-forgotten-export) The symbol "FieldAttrSet" needs to be exported by the entry point index.d.ts +// src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:52:45 - (ae-forgotten-export) The symbol "IndexPatternFieldMap" needs to be exported by the entry point index.d.ts +// src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:65:5 - (ae-forgotten-export) The symbol "FormatFieldFn" needs to be exported by the entry point index.d.ts +// src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:138:7 - (ae-forgotten-export) The symbol "FieldAttrSet" needs to be exported by the entry point index.d.ts +// src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:169:7 - (ae-forgotten-export) The symbol "RuntimeField" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:29:23 - (ae-forgotten-export) The symbol "buildCustomFilter" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:29:23 - (ae-forgotten-export) The symbol "buildFilter" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:46:23 - (ae-forgotten-export) The symbol "datatableToCSV" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/discover/public/application/helpers/get_sharing_data.test.ts b/src/plugins/discover/public/application/helpers/get_sharing_data.test.ts index f72d65dd2ee566..1394ceab1dd18e 100644 --- a/src/plugins/discover/public/application/helpers/get_sharing_data.test.ts +++ b/src/plugins/discover/public/application/helpers/get_sharing_data.test.ts @@ -49,6 +49,7 @@ describe('getSharingData', () => { "should": Array [], }, }, + "runtime_mappings": Object {}, "script_fields": Object {}, "sort": Array [ Object { diff --git a/x-pack/test/functional/apps/maps/mvt_scaling.js b/x-pack/test/functional/apps/maps/mvt_scaling.js index b5c9ddcbd5e138..a7551aca78b52b 100644 --- a/x-pack/test/functional/apps/maps/mvt_scaling.js +++ b/x-pack/test/functional/apps/maps/mvt_scaling.js @@ -29,7 +29,7 @@ export default function ({ getPageObjects, getService }) { //Source should be correct expect(mapboxStyle.sources[VECTOR_SOURCE_ID].tiles[0]).to.equal( - '/api/maps/mvt/getTile?x={x}&y={y}&z={z}&geometryFieldName=geometry&index=geo_shapes*&requestBody=(_source:(includes:!(geometry,prop1)),docvalue_fields:!(prop1),query:(bool:(filter:!((match_all:())),must:!(),must_not:!(),should:!())),script_fields:(),size:10000,stored_fields:!(geometry,prop1))&geoFieldType=geo_shape' + '/api/maps/mvt/getTile?x={x}&y={y}&z={z}&geometryFieldName=geometry&index=geo_shapes*&requestBody=(_source:(includes:!(geometry,prop1)),docvalue_fields:!(prop1),query:(bool:(filter:!((match_all:())),must:!(),must_not:!(),should:!())),runtime_mappings:(),script_fields:(),size:10000,stored_fields:!(geometry,prop1))&geoFieldType=geo_shape' ); //Should correctly load meta for style-rule (sigma is set to 1, opacity to 1) diff --git a/x-pack/test/functional/apps/maps/mvt_super_fine.js b/x-pack/test/functional/apps/maps/mvt_super_fine.js index 3de2f461bc8553..aede736deb2626 100644 --- a/x-pack/test/functional/apps/maps/mvt_super_fine.js +++ b/x-pack/test/functional/apps/maps/mvt_super_fine.js @@ -32,7 +32,7 @@ export default function ({ getPageObjects, getService }) { //Source should be correct expect(mapboxStyle.sources[MB_VECTOR_SOURCE_ID].tiles[0]).to.equal( - "/api/maps/mvt/getGridTile?x={x}&y={y}&z={z}&geometryFieldName=geo.coordinates&index=logstash-*&requestBody=(_source:(excludes:!()),aggs:(gridSplit:(aggs:(gridCentroid:(geo_centroid:(field:geo.coordinates)),max_of_bytes:(max:(field:bytes))),geotile_grid:(bounds:!n,field:geo.coordinates,precision:!n,shard_size:65535,size:65535))),fields:!((field:'@timestamp',format:date_time),(field:'relatedContent.article:modified_time',format:date_time),(field:'relatedContent.article:published_time',format:date_time),(field:utc_time,format:date_time)),query:(bool:(filter:!((match_all:()),(range:('@timestamp':(format:strict_date_optional_time,gte:'2015-09-20T00:00:00.000Z',lte:'2015-09-20T01:00:00.000Z')))),must:!(),must_not:!(),should:!())),script_fields:(hour_of_day:(script:(lang:painless,source:'doc[!'@timestamp!'].value.getHour()'))),size:0,stored_fields:!('*'))&requestType=grid&geoFieldType=geo_point" + "/api/maps/mvt/getGridTile?x={x}&y={y}&z={z}&geometryFieldName=geo.coordinates&index=logstash-*&requestBody=(_source:(excludes:!()),aggs:(gridSplit:(aggs:(gridCentroid:(geo_centroid:(field:geo.coordinates)),max_of_bytes:(max:(field:bytes))),geotile_grid:(bounds:!n,field:geo.coordinates,precision:!n,shard_size:65535,size:65535))),fields:!((field:'@timestamp',format:date_time),(field:'relatedContent.article:modified_time',format:date_time),(field:'relatedContent.article:published_time',format:date_time),(field:utc_time,format:date_time)),query:(bool:(filter:!((match_all:()),(range:('@timestamp':(format:strict_date_optional_time,gte:'2015-09-20T00:00:00.000Z',lte:'2015-09-20T01:00:00.000Z')))),must:!(),must_not:!(),should:!())),runtime_mappings:(),script_fields:(hour_of_day:(script:(lang:painless,source:'doc[!'@timestamp!'].value.getHour()'))),size:0,stored_fields:!('*'))&requestType=grid&geoFieldType=geo_point" ); //Should correctly load meta for style-rule (sigma is set to 1, opacity to 1) From f6689729eae7349ddddc535826385dca948cdff8 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Thu, 21 Jan 2021 21:10:52 +0100 Subject: [PATCH 10/16] Migrate authentication functionality to a new Elasticsearch client. (#87094) --- .../authentication_service.mock.ts | 8 +- .../authentication_service.test.ts | 133 +-- .../authentication/authentication_service.ts | 107 +-- .../authentication/authenticator.test.ts | 26 +- .../server/authentication/authenticator.ts | 10 +- .../security/server/authentication/index.ts | 6 +- .../providers/anonymous.test.ts | 61 +- .../authentication/providers/anonymous.ts | 5 +- .../authentication/providers/base.mock.ts | 2 +- .../server/authentication/providers/base.ts | 12 +- .../authentication/providers/basic.test.ts | 42 +- .../authentication/providers/http.test.ts | 33 +- .../authentication/providers/kerberos.test.ts | 169 ++-- .../authentication/providers/kerberos.ts | 40 +- .../authentication/providers/oidc.test.ts | 256 +++--- .../server/authentication/providers/oidc.ts | 55 +- .../authentication/providers/pki.test.ts | 161 ++-- .../server/authentication/providers/pki.ts | 15 +- .../authentication/providers/saml.test.ts | 485 ++++++---- .../server/authentication/providers/saml.ts | 65 +- .../authentication/providers/token.test.ts | 93 +- .../server/authentication/providers/token.ts | 14 +- .../server/authentication/tokens.test.ts | 252 +++--- .../security/server/authentication/tokens.ts | 24 +- .../elasticsearch_client_plugin.ts | 214 ----- .../elasticsearch_service.test.ts | 64 +- .../elasticsearch/elasticsearch_service.ts | 38 +- .../security/server/elasticsearch/index.ts | 3 +- x-pack/plugins/security/server/mocks.ts | 2 +- x-pack/plugins/security/server/plugin.test.ts | 7 +- x-pack/plugins/security/server/plugin.ts | 116 ++- .../security/server/routes/index.mock.ts | 2 +- .../plugins/security/server/routes/index.ts | 2 +- .../routes/session_management/info.test.ts | 4 +- .../server/routes/session_management/info.ts | 4 +- .../routes/users/change_password.test.ts | 5 +- .../server/routes/users/change_password.ts | 4 +- .../routes/views/access_agreement.test.ts | 4 +- .../server/routes/views/access_agreement.ts | 4 +- .../server/routes/views/logged_out.test.ts | 3 +- .../server/routes/views/logged_out.ts | 4 +- .../server/session_management/index.ts | 2 +- .../session_management/session_index.test.ts | 845 +++++++++--------- .../session_management/session_index.ts | 83 +- .../session_management_service.test.ts | 132 +-- .../session_management_service.ts | 58 +- 46 files changed, 1809 insertions(+), 1865 deletions(-) delete mode 100644 x-pack/plugins/security/server/elasticsearch/elasticsearch_client_plugin.ts diff --git a/x-pack/plugins/security/server/authentication/authentication_service.mock.ts b/x-pack/plugins/security/server/authentication/authentication_service.mock.ts index 06884611f3873d..9c67cf611eb442 100644 --- a/x-pack/plugins/security/server/authentication/authentication_service.mock.ts +++ b/x-pack/plugins/security/server/authentication/authentication_service.mock.ts @@ -5,17 +5,11 @@ */ import type { DeeplyMockedKeys } from '@kbn/utility-types/jest'; -import type { - AuthenticationServiceSetup, - AuthenticationServiceStart, -} from './authentication_service'; +import type { AuthenticationServiceStart } from './authentication_service'; import { apiKeysMock } from './api_keys/api_keys.mock'; export const authenticationServiceMock = { - createSetup: (): jest.Mocked => ({ - getCurrentUser: jest.fn(), - }), createStart: (): DeeplyMockedKeys => ({ apiKeys: apiKeysMock.create(), login: jest.fn(), diff --git a/x-pack/plugins/security/server/authentication/authentication_service.test.ts b/x-pack/plugins/security/server/authentication/authentication_service.test.ts index 59771c50270120..942ddc202360bb 100644 --- a/x-pack/plugins/security/server/authentication/authentication_service.test.ts +++ b/x-pack/plugins/security/server/authentication/authentication_service.test.ts @@ -25,11 +25,9 @@ import { sessionMock } from '../session_management/session.mock'; import type { AuthenticationHandler, AuthToolkit, - ILegacyClusterClient, KibanaRequest, Logger, LoggerFactory, - LegacyScopedClusterClient, HttpServiceSetup, HttpServiceStart, } from '../../../../../src/core/server'; @@ -46,47 +44,17 @@ describe('AuthenticationService', () => { let service: AuthenticationService; let logger: jest.Mocked; let mockSetupAuthenticationParams: { - legacyAuditLogger: jest.Mocked; - audit: jest.Mocked; - config: ConfigType; - loggers: LoggerFactory; http: jest.Mocked; - clusterClient: jest.Mocked; license: jest.Mocked; - getFeatureUsageService: () => jest.Mocked; - session: jest.Mocked>; }; - let mockScopedClusterClient: jest.Mocked>; beforeEach(() => { logger = loggingSystemMock.createLogger(); mockSetupAuthenticationParams = { - legacyAuditLogger: securityAuditLoggerMock.create(), - audit: auditServiceMock.create(), http: coreMock.createSetup().http, - config: createConfig( - ConfigSchema.validate({ - encryptionKey: 'ab'.repeat(16), - secureCookies: true, - cookieName: 'my-sid-cookie', - }), - loggingSystemMock.create().get(), - { isTLSEnabled: false } - ), - clusterClient: elasticsearchServiceMock.createLegacyClusterClient(), license: licenseMock.create(), - loggers: loggingSystemMock.create(), - getFeatureUsageService: jest - .fn() - .mockReturnValue(securityFeatureUsageServiceMock.createStartContract()), - session: sessionMock.create(), }; - mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockSetupAuthenticationParams.clusterClient.asScoped.mockReturnValue( - (mockScopedClusterClient as unknown) as jest.Mocked - ); - service = new AuthenticationService(logger); }); @@ -101,6 +69,42 @@ describe('AuthenticationService', () => { expect.any(Function) ); }); + }); + + describe('#start()', () => { + let mockStartAuthenticationParams: { + legacyAuditLogger: jest.Mocked; + audit: jest.Mocked; + config: ConfigType; + loggers: LoggerFactory; + http: jest.Mocked; + clusterClient: ReturnType; + featureUsageService: jest.Mocked; + session: jest.Mocked>; + }; + beforeEach(() => { + const coreStart = coreMock.createStart(); + mockStartAuthenticationParams = { + legacyAuditLogger: securityAuditLoggerMock.create(), + audit: auditServiceMock.create(), + config: createConfig( + ConfigSchema.validate({ + encryptionKey: 'ab'.repeat(16), + secureCookies: true, + cookieName: 'my-sid-cookie', + }), + loggingSystemMock.create().get(), + { isTLSEnabled: false } + ), + http: coreStart.http, + clusterClient: elasticsearchServiceMock.createClusterClient(), + loggers: loggingSystemMock.create(), + featureUsageService: securityFeatureUsageServiceMock.createStartContract(), + session: sessionMock.create(), + }; + + service.setup(mockSetupAuthenticationParams); + }); describe('authentication handler', () => { let authHandler: AuthenticationHandler; @@ -109,12 +113,7 @@ describe('AuthenticationService', () => { beforeEach(() => { mockAuthToolkit = httpServiceMock.createAuthToolkit(); - service.setup(mockSetupAuthenticationParams); - - expect(mockSetupAuthenticationParams.http.registerAuth).toHaveBeenCalledTimes(1); - expect(mockSetupAuthenticationParams.http.registerAuth).toHaveBeenCalledWith( - expect.any(Function) - ); + service.start(mockStartAuthenticationParams); authHandler = mockSetupAuthenticationParams.http.registerAuth.mock.calls[0][0]; authenticate = jest.requireMock('./authenticator').Authenticator.mock.instances[0] @@ -298,63 +297,7 @@ describe('AuthenticationService', () => { describe('getCurrentUser()', () => { let getCurrentUser: (r: KibanaRequest) => AuthenticatedUser | null; beforeEach(async () => { - getCurrentUser = service.setup(mockSetupAuthenticationParams).getCurrentUser; - }); - - it('returns `null` if Security is disabled', () => { - mockSetupAuthenticationParams.license.isEnabled.mockReturnValue(false); - - expect(getCurrentUser(httpServerMock.createKibanaRequest())).toBe(null); - }); - - it('returns user from the auth state.', () => { - const mockUser = mockAuthenticatedUser(); - - const mockAuthGet = mockSetupAuthenticationParams.http.auth.get as jest.Mock; - mockAuthGet.mockReturnValue({ state: mockUser }); - - const mockRequest = httpServerMock.createKibanaRequest(); - expect(getCurrentUser(mockRequest)).toBe(mockUser); - expect(mockAuthGet).toHaveBeenCalledTimes(1); - expect(mockAuthGet).toHaveBeenCalledWith(mockRequest); - }); - - it('returns null if auth state is not available.', () => { - const mockAuthGet = mockSetupAuthenticationParams.http.auth.get as jest.Mock; - mockAuthGet.mockReturnValue({}); - - const mockRequest = httpServerMock.createKibanaRequest(); - expect(getCurrentUser(mockRequest)).toBeNull(); - expect(mockAuthGet).toHaveBeenCalledTimes(1); - expect(mockAuthGet).toHaveBeenCalledWith(mockRequest); - }); - }); - }); - - describe('#start()', () => { - let mockStartAuthenticationParams: { - http: jest.Mocked; - clusterClient: ReturnType; - }; - beforeEach(() => { - const coreStart = coreMock.createStart(); - mockStartAuthenticationParams = { - http: coreStart.http, - clusterClient: elasticsearchServiceMock.createClusterClient(), - }; - service.setup(mockSetupAuthenticationParams); - }); - - describe('getCurrentUser()', () => { - let getCurrentUser: (r: KibanaRequest) => AuthenticatedUser | null; - beforeEach(async () => { - getCurrentUser = (await service.start(mockStartAuthenticationParams)).getCurrentUser; - }); - - it('returns `null` if Security is disabled', () => { - mockSetupAuthenticationParams.license.isEnabled.mockReturnValue(false); - - expect(getCurrentUser(httpServerMock.createKibanaRequest())).toBe(null); + getCurrentUser = service.start(mockStartAuthenticationParams).getCurrentUser; }); it('returns user from the auth state.', () => { diff --git a/x-pack/plugins/security/server/authentication/authentication_service.ts b/x-pack/plugins/security/server/authentication/authentication_service.ts index e435ae43f3bf33..3ab92d0bd211fe 100644 --- a/x-pack/plugins/security/server/authentication/authentication_service.ts +++ b/x-pack/plugins/security/server/authentication/authentication_service.ts @@ -11,7 +11,6 @@ import type { Logger, HttpServiceSetup, IClusterClient, - ILegacyClusterClient, HttpServiceStart, } from '../../../../../src/core/server'; import type { SecurityLicense } from '../../common/licensing'; @@ -27,27 +26,19 @@ import { APIKeys } from './api_keys'; import { Authenticator, ProviderLoginAttempt } from './authenticator'; interface AuthenticationServiceSetupParams { - legacyAuditLogger: SecurityAuditLogger; - audit: AuditServiceSetup; - getFeatureUsageService: () => SecurityFeatureUsageServiceStart; - http: HttpServiceSetup; - clusterClient: ILegacyClusterClient; - config: ConfigType; + http: Pick; license: SecurityLicense; - loggers: LoggerFactory; - session: PublicMethodsOf; } interface AuthenticationServiceStartParams { - http: HttpServiceStart; + http: Pick; + config: ConfigType; clusterClient: IClusterClient; -} - -export interface AuthenticationServiceSetup { - /** - * @deprecated use `getCurrentUser` from the start contract instead - */ - getCurrentUser: (request: KibanaRequest) => AuthenticatedUser | null; + legacyAuditLogger: SecurityAuditLogger; + audit: AuditServiceSetup; + featureUsageService: SecurityFeatureUsageServiceStart; + session: PublicMethodsOf; + loggers: LoggerFactory; } export interface AuthenticationServiceStart { @@ -67,44 +58,13 @@ export interface AuthenticationServiceStart { export class AuthenticationService { private license!: SecurityLicense; - private authenticator!: Authenticator; + private authenticator?: Authenticator; constructor(private readonly logger: Logger) {} - setup({ - legacyAuditLogger: auditLogger, - audit, - getFeatureUsageService, - http, - clusterClient, - config, - license, - loggers, - session, - }: AuthenticationServiceSetupParams): AuthenticationServiceSetup { + setup({ http, license }: AuthenticationServiceSetupParams) { this.license = license; - const getCurrentUser = (request: KibanaRequest) => { - if (!license.isEnabled()) { - return null; - } - - return http.auth.get(request).state ?? null; - }; - - this.authenticator = new Authenticator({ - legacyAuditLogger: auditLogger, - audit, - loggers, - clusterClient, - basePath: http.basePath, - config: { authc: config.authc }, - getCurrentUser, - getFeatureUsageService, - license, - session, - }); - http.registerAuth(async (request, response, t) => { if (!license.isLicenseAvailable()) { this.logger.error('License is not available, authentication is not possible.'); @@ -123,6 +83,15 @@ export class AuthenticationService { return t.authenticated(); } + if (!this.authenticator) { + this.logger.error('Authentication sub-system is not fully initialized yet.'); + return response.customError({ + body: 'Authentication sub-system is not fully initialized yet.', + statusCode: 503, + headers: { 'Retry-After': '30' }, + }); + } + let authenticationResult; try { authenticationResult = await this.authenticator.authenticate(request); @@ -174,19 +143,40 @@ export class AuthenticationService { }); this.logger.debug('Successfully registered core authentication handler.'); - - return { - getCurrentUser, - }; } - start({ clusterClient, http }: AuthenticationServiceStartParams): AuthenticationServiceStart { + start({ + audit, + config, + clusterClient, + featureUsageService, + http, + legacyAuditLogger, + loggers, + session, + }: AuthenticationServiceStartParams): AuthenticationServiceStart { const apiKeys = new APIKeys({ clusterClient, logger: this.logger.get('api-key'), license: this.license, }); + const getCurrentUser = (request: KibanaRequest) => + http.auth.get(request).state ?? null; + + this.authenticator = new Authenticator({ + legacyAuditLogger, + audit, + loggers, + clusterClient, + basePath: http.basePath, + config: { authc: config.authc }, + getCurrentUser, + featureUsageService, + license: this.license, + session, + }); + return { apiKeys: { areAPIKeysEnabled: apiKeys.areAPIKeysEnabled.bind(apiKeys), @@ -206,12 +196,7 @@ export class AuthenticationService { * Retrieves currently authenticated user associated with the specified request. * @param request */ - getCurrentUser: (request: KibanaRequest) => { - if (!this.license.isEnabled()) { - return null; - } - return http.auth.get(request).state ?? null; - }, + getCurrentUser, }; } } diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index 3d3946fde9f343..08d671d64179a8 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -43,7 +43,7 @@ function getMockOptions({ legacyAuditLogger: securityAuditLoggerMock.create(), audit: auditServiceMock.create(), getCurrentUser: jest.fn(), - clusterClient: elasticsearchServiceMock.createLegacyClusterClient(), + clusterClient: elasticsearchServiceMock.createClusterClient(), basePath: httpServiceMock.createSetupContract().basePath, license: licenseMock.create(), loggers: loggingSystemMock.create(), @@ -53,9 +53,7 @@ function getMockOptions({ { isTLSEnabled: false } ), session: sessionMock.create(), - getFeatureUsageService: jest - .fn() - .mockReturnValue(securityFeatureUsageServiceMock.createStartContract()), + featureUsageService: securityFeatureUsageServiceMock.createStartContract(), }; } @@ -1880,9 +1878,7 @@ describe('Authenticator', () => { ); expect(mockOptions.session.update).not.toHaveBeenCalled(); - expect( - mockOptions.getFeatureUsageService().recordPreAccessAgreementUsage - ).not.toHaveBeenCalled(); + expect(mockOptions.featureUsageService.recordPreAccessAgreementUsage).not.toHaveBeenCalled(); }); it('fails if cannot retrieve user session', async () => { @@ -1895,12 +1891,10 @@ describe('Authenticator', () => { ); expect(mockOptions.session.update).not.toHaveBeenCalled(); - expect( - mockOptions.getFeatureUsageService().recordPreAccessAgreementUsage - ).not.toHaveBeenCalled(); + expect(mockOptions.featureUsageService.recordPreAccessAgreementUsage).not.toHaveBeenCalled(); }); - it('fails if license doesn allow access agreement acknowledgement', async () => { + it('fails if license does not allow access agreement acknowledgement', async () => { mockOptions.license.getFeatures.mockReturnValue({ allowAccessAgreement: false, } as SecurityLicenseFeatures); @@ -1912,9 +1906,7 @@ describe('Authenticator', () => { ); expect(mockOptions.session.update).not.toHaveBeenCalled(); - expect( - mockOptions.getFeatureUsageService().recordPreAccessAgreementUsage - ).not.toHaveBeenCalled(); + expect(mockOptions.featureUsageService.recordPreAccessAgreementUsage).not.toHaveBeenCalled(); }); it('properly acknowledges access agreement for the authenticated user', async () => { @@ -1936,9 +1928,9 @@ describe('Authenticator', () => { } ); - expect( - mockOptions.getFeatureUsageService().recordPreAccessAgreementUsage - ).toHaveBeenCalledTimes(1); + expect(mockOptions.featureUsageService.recordPreAccessAgreementUsage).toHaveBeenCalledTimes( + 1 + ); }); }); }); diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index 85215ebf46fb40..af492bf2477265 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -7,8 +7,8 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import { KibanaRequest, LoggerFactory, - ILegacyClusterClient, IBasePath, + IClusterClient, } from '../../../../../src/core/server'; import { AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, @@ -68,13 +68,13 @@ export interface ProviderLoginAttempt { export interface AuthenticatorOptions { legacyAuditLogger: SecurityAuditLogger; audit: AuditServiceSetup; - getFeatureUsageService: () => SecurityFeatureUsageServiceStart; + featureUsageService: SecurityFeatureUsageServiceStart; getCurrentUser: (request: KibanaRequest) => AuthenticatedUser | null; config: Pick; basePath: IBasePath; license: SecurityLicense; loggers: LoggerFactory; - clusterClient: ILegacyClusterClient; + clusterClient: IClusterClient; session: PublicMethodsOf; } @@ -201,7 +201,7 @@ export class Authenticator { client: this.options.clusterClient, basePath: this.options.basePath, tokens: new Tokens({ - client: this.options.clusterClient, + client: this.options.clusterClient.asInternalUser, logger: this.options.loggers.get('tokens'), }), }; @@ -448,7 +448,7 @@ export class Authenticator { existingSessionValue.provider ); - this.options.getFeatureUsageService().recordPreAccessAgreementUsage(); + this.options.featureUsageService.recordPreAccessAgreementUsage(); } /** diff --git a/x-pack/plugins/security/server/authentication/index.ts b/x-pack/plugins/security/server/authentication/index.ts index c87a02c9545c14..e745e1c5717b36 100644 --- a/x-pack/plugins/security/server/authentication/index.ts +++ b/x-pack/plugins/security/server/authentication/index.ts @@ -5,11 +5,7 @@ */ export { canRedirectRequest } from './can_redirect_request'; -export { - AuthenticationService, - AuthenticationServiceSetup, - AuthenticationServiceStart, -} from './authentication_service'; +export { AuthenticationService, AuthenticationServiceStart } from './authentication_service'; export { AuthenticationResult } from './authentication_result'; export { DeauthenticationResult } from './deauthentication_result'; export { diff --git a/x-pack/plugins/security/server/authentication/providers/anonymous.test.ts b/x-pack/plugins/security/server/authentication/providers/anonymous.test.ts index 9674181e187501..a2db413319546b 100644 --- a/x-pack/plugins/security/server/authentication/providers/anonymous.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/anonymous.test.ts @@ -4,11 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ +import { errors } from '@elastic/elasticsearch'; + import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import { securityMock } from '../../mocks'; import { mockAuthenticationProviderOptions } from './base.mock'; -import { ILegacyClusterClient, ScopeableRequest } from '../../../../../../src/core/server'; +import { ScopeableRequest } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { @@ -18,15 +21,14 @@ import { import { AnonymousAuthenticationProvider } from './anonymous'; function expectAuthenticateCall( - mockClusterClient: jest.Mocked, + mockClusterClient: ReturnType, scopeableRequest: ScopeableRequest ) { expect(mockClusterClient.asScoped).toHaveBeenCalledTimes(1); expect(mockClusterClient.asScoped).toHaveBeenCalledWith(scopeableRequest); const mockScopedClusterClient = mockClusterClient.asScoped.mock.results[0].value; - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expect(mockScopedClusterClient.asCurrentUser.security.authenticate).toHaveBeenCalledTimes(1); } enum CredentialsType { @@ -75,8 +77,10 @@ describe('AnonymousAuthenticationProvider', () => { describe('`login` method', () => { it('succeeds if credentials are valid, and creates session and authHeaders', async () => { - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResolvedValue( + securityMock.createApiResponse({ body: user }) + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect( @@ -92,10 +96,13 @@ describe('AnonymousAuthenticationProvider', () => { it('fails if user cannot be retrieved during login attempt', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); - - const authenticationError = new Error('Some error'); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(authenticationError); + const authenticationError = new errors.ResponseError( + securityMock.createApiResponse({ body: {} }) + ); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + authenticationError + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(provider.login(request)).resolves.toEqual( @@ -155,8 +162,10 @@ describe('AnonymousAuthenticationProvider', () => { it('succeeds for non-AJAX requests if state is available.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResolvedValue( + securityMock.createApiResponse({ body: user }) + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(provider.authenticate(request, {})).resolves.toEqual( @@ -169,8 +178,10 @@ describe('AnonymousAuthenticationProvider', () => { it('succeeds for AJAX requests if state is available.', async () => { const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResolvedValue( + securityMock.createApiResponse({ body: user }) + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(provider.authenticate(request, {})).resolves.toEqual( @@ -185,8 +196,10 @@ describe('AnonymousAuthenticationProvider', () => { it('non-AJAX requests can start a new session.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResolvedValue( + securityMock.createApiResponse({ body: user }) + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(provider.authenticate(request)).resolves.toEqual( @@ -199,9 +212,13 @@ describe('AnonymousAuthenticationProvider', () => { it('fails if credentials are not valid.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); - const authenticationError = new Error('Forbidden'); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(authenticationError); + const authenticationError = new errors.ResponseError( + securityMock.createApiResponse({ body: {} }) + ); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + authenticationError + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(provider.authenticate(request)).resolves.toEqual( @@ -225,8 +242,10 @@ describe('AnonymousAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResolvedValue( + securityMock.createApiResponse({ body: user }) + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(provider.authenticate(request, {})).resolves.toEqual( diff --git a/x-pack/plugins/security/server/authentication/providers/anonymous.ts b/x-pack/plugins/security/server/authentication/providers/anonymous.ts index 1585b0592b356b..249b4adea7bba2 100644 --- a/x-pack/plugins/security/server/authentication/providers/anonymous.ts +++ b/x-pack/plugins/security/server/authentication/providers/anonymous.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { KibanaRequest, LegacyElasticsearchErrorHelpers } from '../../../../../../src/core/server'; +import { KibanaRequest } from '../../../../../../src/core/server'; +import { getErrorStatusCode } from '../../errors'; import { AuthenticationResult } from '../authentication_result'; import { canRedirectRequest } from '../can_redirect_request'; import { DeauthenticationResult } from '../deauthentication_result'; @@ -213,7 +214,7 @@ export class AnonymousAuthenticationProvider extends BaseAuthenticationProvider // Create session only if it doesn't exist yet, otherwise keep it unchanged. return AuthenticationResult.succeeded(user, { authHeaders, state: state ? undefined : {} }); } catch (err) { - if (LegacyElasticsearchErrorHelpers.isNotAuthorizedError(err)) { + if (getErrorStatusCode(err) === 401) { if (!this.httpAuthorizationHeader) { this.logger.error( `Failed to authenticate anonymous request using Elasticsearch reserved anonymous user. Anonymous access may not be properly configured in Elasticsearch: ${err.message}` diff --git a/x-pack/plugins/security/server/authentication/providers/base.mock.ts b/x-pack/plugins/security/server/authentication/providers/base.mock.ts index 47d961bc8faf8e..3eea6f9aadadf2 100644 --- a/x-pack/plugins/security/server/authentication/providers/base.mock.ts +++ b/x-pack/plugins/security/server/authentication/providers/base.mock.ts @@ -16,7 +16,7 @@ export type MockAuthenticationProviderOptions = ReturnType< export function mockAuthenticationProviderOptions(options?: { name: string }) { return { - client: elasticsearchServiceMock.createLegacyClusterClient(), + client: elasticsearchServiceMock.createClusterClient(), logger: loggingSystemMock.create().get(), basePath: httpServiceMock.createBasePath(), tokens: { refresh: jest.fn(), invalidate: jest.fn() }, diff --git a/x-pack/plugins/security/server/authentication/providers/base.ts b/x-pack/plugins/security/server/authentication/providers/base.ts index f1845617c87a4e..73449cf1077fe1 100644 --- a/x-pack/plugins/security/server/authentication/providers/base.ts +++ b/x-pack/plugins/security/server/authentication/providers/base.ts @@ -10,8 +10,8 @@ import { KibanaRequest, Logger, HttpServiceSetup, - ILegacyClusterClient, Headers, + IClusterClient, } from '../../../../../../src/core/server'; import type { AuthenticatedUser } from '../../../common/model'; import type { AuthenticationInfo } from '../../elasticsearch'; @@ -25,7 +25,7 @@ import { Tokens } from '../tokens'; export interface AuthenticationProviderOptions { name: string; basePath: HttpServiceSetup['basePath']; - client: ILegacyClusterClient; + client: IClusterClient; logger: Logger; tokens: PublicMethodsOf; urls: { @@ -111,9 +111,11 @@ export abstract class BaseAuthenticationProvider { */ protected async getUser(request: KibanaRequest, authHeaders: Headers = {}) { return this.authenticationInfoToAuthenticatedUser( - await this.options.client - .asScoped({ headers: { ...request.headers, ...authHeaders } }) - .callAsCurrentUser('shield.authenticate') + ( + await this.options.client + .asScoped({ headers: { ...request.headers, ...authHeaders } }) + .asCurrentUser.security.authenticate() + ).body ); } diff --git a/x-pack/plugins/security/server/authentication/providers/basic.test.ts b/x-pack/plugins/security/server/authentication/providers/basic.test.ts index 4f93e2327da061..e7cf3d95b08278 100644 --- a/x-pack/plugins/security/server/authentication/providers/basic.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/basic.test.ts @@ -4,11 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ +import { errors } from '@elastic/elasticsearch'; + import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import { securityMock } from '../../mocks'; import { mockAuthenticationProviderOptions } from './base.mock'; -import { ILegacyClusterClient, ScopeableRequest } from '../../../../../../src/core/server'; +import { ScopeableRequest } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { BasicAuthenticationProvider } from './basic'; @@ -18,15 +21,14 @@ function generateAuthorizationHeader(username: string, password: string) { } function expectAuthenticateCall( - mockClusterClient: jest.Mocked, + mockClusterClient: ReturnType, scopeableRequest: ScopeableRequest ) { expect(mockClusterClient.asScoped).toHaveBeenCalledTimes(1); expect(mockClusterClient.asScoped).toHaveBeenCalledWith(scopeableRequest); const mockScopedClusterClient = mockClusterClient.asScoped.mock.results[0].value; - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expect(mockScopedClusterClient.asCurrentUser.security.authenticate).toHaveBeenCalledTimes(1); } describe('BasicAuthenticationProvider', () => { @@ -45,8 +47,10 @@ describe('BasicAuthenticationProvider', () => { const credentials = { username: 'user', password: 'password' }; const authorization = generateAuthorizationHeader(credentials.username, credentials.password); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResolvedValue( + securityMock.createApiResponse({ body: user }) + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect( @@ -66,9 +70,13 @@ describe('BasicAuthenticationProvider', () => { const credentials = { username: 'user', password: 'password' }; const authorization = generateAuthorizationHeader(credentials.username, credentials.password); - const authenticationError = new Error('Some error'); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(authenticationError); + const authenticationError = new errors.ResponseError( + securityMock.createApiResponse({ body: {} }) + ); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + authenticationError + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(provider.login(request, credentials)).resolves.toEqual( @@ -149,8 +157,10 @@ describe('BasicAuthenticationProvider', () => { const user = mockAuthenticatedUser(); const authorization = generateAuthorizationHeader('user', 'password'); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResolvedValue( + securityMock.createApiResponse({ body: user }) + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(provider.authenticate(request, { authorization })).resolves.toEqual( @@ -164,9 +174,13 @@ describe('BasicAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); const authorization = generateAuthorizationHeader('user', 'password'); - const authenticationError = new Error('Forbidden'); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(authenticationError); + const authenticationError = new errors.ResponseError( + securityMock.createApiResponse({ body: {} }) + ); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + authenticationError + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(provider.authenticate(request, { authorization })).resolves.toEqual( diff --git a/x-pack/plugins/security/server/authentication/providers/http.test.ts b/x-pack/plugins/security/server/authentication/providers/http.test.ts index 512a8ead2c32b0..b8a2a110d45b0b 100644 --- a/x-pack/plugins/security/server/authentication/providers/http.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/http.test.ts @@ -4,29 +4,27 @@ * you may not use this file except in compliance with the Elastic License. */ +import { errors } from '@elastic/elasticsearch'; + import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import { securityMock } from '../../mocks'; import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions } from './base.mock'; -import { - LegacyElasticsearchErrorHelpers, - ILegacyClusterClient, - ScopeableRequest, -} from '../../../../../../src/core/server'; +import { ScopeableRequest } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { HTTPAuthenticationProvider } from './http'; function expectAuthenticateCall( - mockClusterClient: jest.Mocked, + mockClusterClient: ReturnType, scopeableRequest: ScopeableRequest ) { expect(mockClusterClient.asScoped).toHaveBeenCalledTimes(1); expect(mockClusterClient.asScoped).toHaveBeenCalledWith(scopeableRequest); const mockScopedClusterClient = mockClusterClient.asScoped.mock.results[0].value; - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expect(mockScopedClusterClient.asCurrentUser.security.authenticate).toHaveBeenCalledTimes(1); } describe('HTTPAuthenticationProvider', () => { @@ -58,7 +56,6 @@ describe('HTTPAuthenticationProvider', () => { await expect(provider.login()).resolves.toEqual(AuthenticationResult.notHandled()); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); }); @@ -73,7 +70,6 @@ describe('HTTPAuthenticationProvider', () => { ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); it('does not handle authentication for requests with empty scheme in `authorization` header.', async () => { @@ -88,7 +84,6 @@ describe('HTTPAuthenticationProvider', () => { ).resolves.toEqual(AuthenticationResult.notHandled()); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); it('does not handle authentication via `authorization` header if scheme is not supported.', async () => { @@ -112,7 +107,6 @@ describe('HTTPAuthenticationProvider', () => { } expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); it('succeeds if authentication via `authorization` header with supported scheme succeeds.', async () => { @@ -126,8 +120,10 @@ describe('HTTPAuthenticationProvider', () => { ]) { const request = httpServerMock.createKibanaRequest({ headers: { authorization: header } }); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResolvedValue( + securityMock.createApiResponse({ body: user }) + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.client.asScoped.mockClear(); @@ -149,7 +145,7 @@ describe('HTTPAuthenticationProvider', () => { }); it('fails if authentication via `authorization` header with supported scheme fails.', async () => { - const failureReason = LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); + const failureReason = new errors.ResponseError(securityMock.createApiResponse({ body: {} })); for (const { schemes, header } of [ { schemes: ['basic'], header: 'Basic xxx' }, { schemes: ['bearer'], header: 'Bearer xxx' }, @@ -159,8 +155,10 @@ describe('HTTPAuthenticationProvider', () => { ]) { const request = httpServerMock.createKibanaRequest({ headers: { authorization: header } }); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + failureReason + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.client.asScoped.mockClear(); @@ -188,7 +186,6 @@ describe('HTTPAuthenticationProvider', () => { await expect(provider.logout()).resolves.toEqual(DeauthenticationResult.notHandled()); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts index d368bf90cf360e..f8b7b42d1845bc 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts @@ -5,32 +5,27 @@ */ import Boom from '@hapi/boom'; -import { errors } from 'elasticsearch'; +import { errors } from '@elastic/elasticsearch'; import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import { securityMock } from '../../mocks'; import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions } from './base.mock'; -import { - LegacyElasticsearchErrorHelpers, - ILegacyClusterClient, - KibanaRequest, - ScopeableRequest, -} from '../../../../../../src/core/server'; +import { KibanaRequest, ScopeableRequest } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { KerberosAuthenticationProvider } from './kerberos'; function expectAuthenticateCall( - mockClusterClient: jest.Mocked, + mockClusterClient: ReturnType, scopeableRequest: ScopeableRequest ) { expect(mockClusterClient.asScoped).toHaveBeenCalledTimes(1); expect(mockClusterClient.asScoped).toHaveBeenCalledWith(scopeableRequest); const mockScopedClusterClient = mockClusterClient.asScoped.mock.results[0].value; - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expect(mockScopedClusterClient.asCurrentUser.security.authenticate).toHaveBeenCalledTimes(1); } describe('KerberosAuthenticationProvider', () => { @@ -47,8 +42,10 @@ describe('KerberosAuthenticationProvider', () => { it('does not handle requests that can be authenticated without `Negotiate` header.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue({}); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResolvedValue( + securityMock.createApiResponse({ body: {} }) + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled()); @@ -61,9 +58,9 @@ describe('KerberosAuthenticationProvider', () => { it('does not handle requests if backend does not support Kerberos.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); @@ -77,17 +74,18 @@ describe('KerberosAuthenticationProvider', () => { it('fails with `Negotiate` challenge if backend supports Kerberos.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); - const failureReason = LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError( - new (errors.AuthenticationException as any)('Unauthorized', { + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ + statusCode: 401, body: { error: { header: { 'WWW-Authenticate': 'Negotiate' } } }, }) ); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue(failureReason); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(operation(request)).resolves.toEqual( - AuthenticationResult.failed(failureReason, { + AuthenticationResult.failed(Boom.unauthorized(), { authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, }) ); @@ -100,9 +98,12 @@ describe('KerberosAuthenticationProvider', () => { it('fails if request authentication is failed with non-401 error.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); - const failureReason = new errors.ServiceUnavailable(); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + const failureReason = new errors.NoLivingConnectionsError( + 'Unavailable', + securityMock.createApiResponse({ statusCode: 500, body: {} }) + ); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue(failureReason); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(operation(request)).resolves.toEqual(AuthenticationResult.failed(failureReason)); @@ -118,11 +119,15 @@ describe('KerberosAuthenticationProvider', () => { headers: { authorization: 'negotiate spnego' }, }); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - access_token: 'some-token', - refresh_token: 'some-refresh-token', - authentication: user, - }); + mockOptions.client.asInternalUser.security.getToken.mockResolvedValue( + securityMock.createApiResponse({ + body: { + access_token: 'some-token', + refresh_token: 'some-refresh-token', + authentication: user, + }, + }) + ); await expect(operation(request)).resolves.toEqual( AuthenticationResult.succeeded( @@ -135,7 +140,7 @@ describe('KerberosAuthenticationProvider', () => { ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { + expect(mockOptions.client.asInternalUser.security.getToken).toHaveBeenCalledWith({ body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' }, }); @@ -148,12 +153,16 @@ describe('KerberosAuthenticationProvider', () => { headers: { authorization: 'negotiate spnego' }, }); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - access_token: 'some-token', - refresh_token: 'some-refresh-token', - kerberos_authentication_response_token: 'response-token', - authentication: user, - }); + mockOptions.client.asInternalUser.security.getToken.mockResolvedValue( + securityMock.createApiResponse({ + body: { + access_token: 'some-token', + refresh_token: 'some-refresh-token', + kerberos_authentication_response_token: 'response-token', + authentication: user, + }, + }) + ); await expect(operation(request)).resolves.toEqual( AuthenticationResult.succeeded( @@ -167,7 +176,7 @@ describe('KerberosAuthenticationProvider', () => { ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { + expect(mockOptions.client.asInternalUser.security.getToken).toHaveBeenCalledWith({ body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' }, }); @@ -179,12 +188,13 @@ describe('KerberosAuthenticationProvider', () => { headers: { authorization: 'negotiate spnego' }, }); - const failureReason = LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError( - new (errors.AuthenticationException as any)('Unauthorized', { + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ + statusCode: 401, body: { error: { header: { 'WWW-Authenticate': 'Negotiate response-token' } } }, }) ); - mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); + mockOptions.client.asInternalUser.security.getToken.mockRejectedValue(failureReason); await expect(operation(request)).resolves.toEqual( AuthenticationResult.failed(Boom.unauthorized(), { @@ -192,7 +202,7 @@ describe('KerberosAuthenticationProvider', () => { }) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { + expect(mockOptions.client.asInternalUser.security.getToken).toHaveBeenCalledWith({ body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' }, }); @@ -204,12 +214,13 @@ describe('KerberosAuthenticationProvider', () => { headers: { authorization: 'negotiate spnego' }, }); - const failureReason = LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError( - new (errors.AuthenticationException as any)('Unauthorized', { + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ + statusCode: 401, body: { error: { header: { 'WWW-Authenticate': 'Negotiate' } } }, }) ); - mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); + mockOptions.client.asInternalUser.security.getToken.mockRejectedValue(failureReason); await expect(operation(request)).resolves.toEqual( AuthenticationResult.failed(Boom.unauthorized(), { @@ -217,7 +228,7 @@ describe('KerberosAuthenticationProvider', () => { }) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { + expect(mockOptions.client.asInternalUser.security.getToken).toHaveBeenCalledWith({ body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' }, }); @@ -229,12 +240,14 @@ describe('KerberosAuthenticationProvider', () => { headers: { authorization: 'negotiate spnego' }, }); - const failureReason = LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); - mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 401, body: {} }) + ); + mockOptions.client.asInternalUser.security.getToken.mockRejectedValue(failureReason); await expect(operation(request)).resolves.toEqual(AuthenticationResult.failed(failureReason)); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { + expect(mockOptions.client.asInternalUser.security.getToken).toHaveBeenCalledWith({ body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' }, }); @@ -259,7 +272,7 @@ describe('KerberosAuthenticationProvider', () => { ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.security.getToken).not.toHaveBeenCalled(); expect(request.headers.authorization).toBe('Bearer some-token'); }); @@ -277,7 +290,7 @@ describe('KerberosAuthenticationProvider', () => { ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.security.getToken).not.toHaveBeenCalled(); expect(request.headers.authorization).toBe('Bearer some-token'); }); @@ -285,14 +298,15 @@ describe('KerberosAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'token', refreshToken: 'refresh-token' }; - const failureReason = LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.tokens.refresh.mockResolvedValue(null); await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( - AuthenticationResult.failed(failureReason) + AuthenticationResult.failed(Boom.unauthorized()) ); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); @@ -306,7 +320,7 @@ describe('KerberosAuthenticationProvider', () => { ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.security.getToken).not.toHaveBeenCalled(); }); it('does not start SPNEGO for Ajax requests.', async () => { @@ -316,7 +330,7 @@ describe('KerberosAuthenticationProvider', () => { ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.security.getToken).not.toHaveBeenCalled(); }); it('succeeds if state contains a valid token.', async () => { @@ -328,8 +342,10 @@ describe('KerberosAuthenticationProvider', () => { }; const authorization = `Bearer ${tokenPair.accessToken}`; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResolvedValue( + securityMock.createApiResponse({ body: user }) + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( @@ -349,9 +365,9 @@ describe('KerberosAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); @@ -384,9 +400,11 @@ describe('KerberosAuthenticationProvider', () => { refreshToken: 'some-valid-refresh-token', }; - const failureReason = new errors.InternalServerError('Token is not valid!'); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 503, body: {} }) + ); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue(failureReason); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( @@ -396,23 +414,22 @@ describe('KerberosAuthenticationProvider', () => { expectAuthenticateCall(mockOptions.client, { headers: { authorization: `Bearer ${tokenPair.accessToken}` }, }); - - expect(mockScopedClusterClient.callAsInternalUser).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.security.getToken).not.toHaveBeenCalled(); expect(request.headers).not.toHaveProperty('authorization'); }); it('fails with `Negotiate` challenge if both access and refresh tokens from the state are expired and backend supports Kerberos.', async () => { - const failureReason = LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError( - new (errors.AuthenticationException as any)('Unauthorized', { - body: { error: { header: { 'WWW-Authenticate': 'Negotiate' } } }, - }) + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError( + securityMock.createApiResponse({ + statusCode: 401, + body: { error: { header: { 'WWW-Authenticate': 'Negotiate' } } }, + }) + ) ); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - mockOptions.tokens.refresh.mockResolvedValue(null); const nonAjaxRequest = httpServerMock.createKibanaRequest(); @@ -421,7 +438,7 @@ describe('KerberosAuthenticationProvider', () => { refreshToken: 'some-valid-refresh-token', }; await expect(provider.authenticate(nonAjaxRequest, nonAjaxTokenPair)).resolves.toEqual( - AuthenticationResult.failed(failureReason, { + AuthenticationResult.failed(Boom.unauthorized(), { authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, }) ); @@ -432,7 +449,7 @@ describe('KerberosAuthenticationProvider', () => { refreshToken: 'ajax-some-valid-refresh-token', }; await expect(provider.authenticate(ajaxRequest, ajaxTokenPair)).resolves.toEqual( - AuthenticationResult.failed(failureReason, { + AuthenticationResult.failed(Boom.unauthorized(), { authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, }) ); @@ -445,7 +462,7 @@ describe('KerberosAuthenticationProvider', () => { await expect( provider.authenticate(optionalAuthRequest, optionalAuthTokenPair) ).resolves.toEqual( - AuthenticationResult.failed(failureReason, { + AuthenticationResult.failed(Boom.unauthorized(), { authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, }) ); diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.ts index b7abed979164e3..a02f6a8dfb9451 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.ts @@ -5,12 +5,10 @@ */ import Boom from '@hapi/boom'; -import { - LegacyElasticsearchError, - LegacyElasticsearchErrorHelpers, - KibanaRequest, -} from '../../../../../../src/core/server'; +import { errors } from '@elastic/elasticsearch'; +import type { KibanaRequest } from '../../../../../../src/core/server'; import type { AuthenticationInfo } from '../../elasticsearch'; +import { getDetailedErrorMessage, getErrorStatusCode } from '../../errors'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { HTTPAuthorizationHeader } from '../http_authentication'; @@ -153,16 +151,21 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { authentication: AuthenticationInfo; }; try { - tokens = await this.options.client.callAsInternalUser('shield.getAccessToken', { - body: { grant_type: '_kerberos', kerberos_ticket: kerberosTicket }, - }); + tokens = ( + await this.options.client.asInternalUser.security.getToken({ + body: { grant_type: '_kerberos', kerberos_ticket: kerberosTicket }, + }) + ).body; } catch (err) { - this.logger.debug(`Failed to exchange SPNEGO token for an access token: ${err.message}`); + this.logger.debug( + `Failed to exchange SPNEGO token for an access token: ${getDetailedErrorMessage(err)}` + ); // Check if SPNEGO context wasn't established and we have a response token to return to the client. - const challenge = LegacyElasticsearchErrorHelpers.isNotAuthorizedError(err) - ? this.getNegotiateChallenge(err) - : undefined; + const challenge = + getErrorStatusCode(err) === 401 && err instanceof errors.ResponseError + ? this.getNegotiateChallenge(err) + : undefined; if (!challenge) { return AuthenticationResult.failed(err); } @@ -292,7 +295,7 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug('Trying to authenticate request via SPNEGO.'); // Try to authenticate current request with Elasticsearch to see whether it supports SPNEGO. - let elasticsearchError: LegacyElasticsearchError; + let elasticsearchError: errors.ResponseError; try { await this.getUser(request, { // We should send a fake SPNEGO token to Elasticsearch to make sure Kerberos realm is included @@ -306,7 +309,7 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { } catch (err) { // Fail immediately if we get unexpected error (e.g. ES isn't available). We should not touch // session cookie in this case. - if (!LegacyElasticsearchErrorHelpers.isNotAuthorizedError(err)) { + if (getErrorStatusCode(err) !== 401 || !(err instanceof errors.ResponseError)) { return AuthenticationResult.failed(err); } @@ -332,11 +335,14 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { * Extracts `Negotiate` challenge from the list of challenges returned with Elasticsearch error if any. * @param error Error to extract challenges from. */ - private getNegotiateChallenge(error: LegacyElasticsearchError) { + private getNegotiateChallenge(error: errors.ResponseError) { + // We extract headers from the original Elasticsearch error and not from the top-level `headers` + // property of the Elasticsearch client error since client merges multiple `WWW-Authenticate` + // headers into one using comma as a separator. That makes it hard to correctly parse the header + // since `WWW-Authenticate` values can also include commas. const challenges = ([] as string[]).concat( - (error.output.headers as { [key: string]: string })[WWWAuthenticateHeaderName] + error.body?.error?.header?.[WWWAuthenticateHeaderName] || [] ); - const negotiateChallenge = challenges.find((challenge) => challenge.toLowerCase().startsWith('negotiate') ); diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts index 9988ddd99c3953..8037b067852d86 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts @@ -5,16 +5,14 @@ */ import Boom from '@hapi/boom'; +import { errors } from '@elastic/elasticsearch'; import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import { securityMock } from '../../mocks'; import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions } from './base.mock'; -import { - LegacyElasticsearchErrorHelpers, - KibanaRequest, - ILegacyScopedClusterClient, -} from '../../../../../../src/core/server'; +import { KibanaRequest } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { OIDCAuthenticationProvider, OIDCLogin, ProviderLoginAttempt } from './oidc'; @@ -24,19 +22,18 @@ describe('OIDCAuthenticationProvider', () => { let provider: OIDCAuthenticationProvider; let mockOptions: MockAuthenticationProviderOptions; let mockUser: AuthenticatedUser; - let mockScopedClusterClient: jest.Mocked; + let mockScopedClusterClient: ReturnType< + typeof elasticsearchServiceMock.createScopedClusterClient + >; beforeEach(() => { mockOptions = mockAuthenticationProviderOptions({ name: 'oidc' }); - mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); + mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); mockUser = mockAuthenticatedUser({ authentication_provider: { type: 'oidc', name: 'oidc' } }); - mockScopedClusterClient.callAsCurrentUser.mockImplementation(async (method) => { - if (method === 'shield.authenticate') { - return mockUser; - } - - throw new Error(`Unexpected call to ${method}!`); - }); + mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResolvedValue( + securityMock.createApiResponse({ body: mockUser }) + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); provider = new OIDCAuthenticationProvider(mockOptions, { realm: 'oidc1' }); @@ -60,17 +57,21 @@ describe('OIDCAuthenticationProvider', () => { it('redirects third party initiated login attempts to the OpenId Connect Provider.', async () => { const request = httpServerMock.createKibanaRequest({ path: '/api/security/oidc/callback' }); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - state: 'statevalue', - nonce: 'noncevalue', - redirect: - 'https://op-host/path/login?response_type=code' + - '&scope=openid%20profile%20email' + - '&client_id=s6BhdRkqt3' + - '&state=statevalue' + - '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc' + - '&login_hint=loginhint', - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { + state: 'statevalue', + nonce: 'noncevalue', + redirect: + 'https://op-host/path/login?response_type=code' + + '&scope=openid%20profile%20email' + + '&client_id=s6BhdRkqt3' + + '&state=statevalue' + + '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc' + + '&login_hint=loginhint', + }, + }) + ); await expect( provider.login(request, { @@ -97,7 +98,10 @@ describe('OIDCAuthenticationProvider', () => { ) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcPrepare', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/oidc/prepare', body: { iss: 'theissuer', login_hint: 'loginhint' }, }); }); @@ -105,17 +109,21 @@ describe('OIDCAuthenticationProvider', () => { it('redirects user initiated login attempts to the OpenId Connect Provider.', async () => { const request = httpServerMock.createKibanaRequest(); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - state: 'statevalue', - nonce: 'noncevalue', - redirect: - 'https://op-host/path/login?response_type=code' + - '&scope=openid%20profile%20email' + - '&client_id=s6BhdRkqt3' + - '&state=statevalue' + - '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc' + - '&login_hint=loginhint', - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { + state: 'statevalue', + nonce: 'noncevalue', + redirect: + 'https://op-host/path/login?response_type=code' + + '&scope=openid%20profile%20email' + + '&client_id=s6BhdRkqt3' + + '&state=statevalue' + + '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc' + + '&login_hint=loginhint', + }, + }) + ); await expect( provider.login(request, { @@ -141,7 +149,10 @@ describe('OIDCAuthenticationProvider', () => { ) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcPrepare', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/oidc/prepare', body: { realm: 'oidc1' }, }); }); @@ -149,8 +160,10 @@ describe('OIDCAuthenticationProvider', () => { it('fails if OpenID Connect authentication request preparation fails.', async () => { const request = httpServerMock.createKibanaRequest(); - const failureReason = new Error('Realm is misconfigured!'); - mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 503, body: {} }) + ); + mockOptions.client.asInternalUser.transport.request.mockRejectedValue(failureReason); await expect( provider.login(request, { @@ -159,8 +172,11 @@ describe('OIDCAuthenticationProvider', () => { }) ).resolves.toEqual(AuthenticationResult.failed(failureReason)); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcPrepare', { - body: { realm: `oidc1` }, + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/oidc/prepare', + body: { realm: 'oidc1' }, }); }); @@ -174,11 +190,15 @@ describe('OIDCAuthenticationProvider', () => { it('gets token and redirects user to requested URL if OIDC authentication response is valid.', async () => { const { request, attempt, expectedRedirectURI } = getMocks(); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - authentication: mockUser, - access_token: 'some-token', - refresh_token: 'some-refresh-token', - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { + authentication: mockUser, + access_token: 'some-token', + refresh_token: 'some-refresh-token', + }, + }) + ); await expect( provider.login(request, attempt, { @@ -198,17 +218,17 @@ describe('OIDCAuthenticationProvider', () => { }) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( - 'shield.oidcAuthenticate', - { - body: { - state: 'statevalue', - nonce: 'noncevalue', - redirect_uri: expectedRedirectURI, - realm: 'oidc1', - }, - } - ); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/oidc/authenticate', + body: { + state: 'statevalue', + nonce: 'noncevalue', + redirect_uri: expectedRedirectURI, + realm: 'oidc1', + }, + }); }); it('fails if authentication response is presented but session state does not contain the state parameter.', async () => { @@ -224,7 +244,7 @@ describe('OIDCAuthenticationProvider', () => { ) ); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); it('fails if authentication response is presented but session state does not contain redirect URL.', async () => { @@ -244,7 +264,7 @@ describe('OIDCAuthenticationProvider', () => { ) ); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); it('fails if session state is not presented.', async () => { @@ -258,16 +278,19 @@ describe('OIDCAuthenticationProvider', () => { ) ); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); it('fails if authentication response is not valid.', async () => { const { request, attempt, expectedRedirectURI } = getMocks(); - const failureReason = new Error( - 'Failed to exchange code for Id Token using the Token Endpoint.' + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ + statusCode: 400, + body: { message: 'Failed to exchange code for Id Token using the Token Endpoint.' }, + }) ); - mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); + mockOptions.client.asInternalUser.transport.request.mockRejectedValue(failureReason); await expect( provider.login(request, attempt, { @@ -278,17 +301,17 @@ describe('OIDCAuthenticationProvider', () => { }) ).resolves.toEqual(AuthenticationResult.failed(failureReason)); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( - 'shield.oidcAuthenticate', - { - body: { - state: 'statevalue', - nonce: 'noncevalue', - redirect_uri: expectedRedirectURI, - realm: 'oidc1', - }, - } - ); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/oidc/authenticate', + body: { + state: 'statevalue', + nonce: 'noncevalue', + redirect_uri: expectedRedirectURI, + realm: 'oidc1', + }, + }); }); it('fails if realm from state is different from the realm provider is configured with.', async () => { @@ -302,7 +325,7 @@ describe('OIDCAuthenticationProvider', () => { ) ); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); } @@ -353,11 +376,6 @@ describe('OIDCAuthenticationProvider', () => { it('redirects non-AJAX request that can not be authenticated to the "capture URL" page.', async () => { const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path' }); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - id: 'some-request-id', - redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', - }); - await expect(provider.authenticate(request, null)).resolves.toEqual( AuthenticationResult.redirectTo( '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path&providerType=oidc&providerName=oidc', @@ -365,7 +383,7 @@ describe('OIDCAuthenticationProvider', () => { ) ); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); it('succeeds if state contains a valid token.', async () => { @@ -425,8 +443,10 @@ describe('OIDCAuthenticationProvider', () => { }; const authorization = `Bearer ${tokenPair.accessToken}`; - const failureReason = new Error('Token is not valid!'); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 400, body: {} }) + ); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue(failureReason); await expect( provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) @@ -441,8 +461,8 @@ describe('OIDCAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'expired-token', refreshToken: 'valid-refresh-token' }; - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); mockOptions.tokens.refresh.mockResolvedValue({ @@ -475,8 +495,8 @@ describe('OIDCAuthenticationProvider', () => { const tokenPair = { accessToken: 'expired-token', refreshToken: 'invalid-refresh-token' }; const authorization = `Bearer ${tokenPair.accessToken}`; - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); const refreshFailureReason = { @@ -502,8 +522,8 @@ describe('OIDCAuthenticationProvider', () => { const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' }; const authorization = `Bearer ${tokenPair.accessToken}`; - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); mockOptions.tokens.refresh.mockResolvedValue(null); @@ -524,10 +544,9 @@ describe('OIDCAuthenticationProvider', () => { expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization }, }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expect(mockScopedClusterClient.asCurrentUser.security.authenticate).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); it('fails for AJAX requests with user friendly message if refresh token is expired.', async () => { @@ -535,8 +554,8 @@ describe('OIDCAuthenticationProvider', () => { const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' }; const authorization = `Bearer ${tokenPair.accessToken}`; - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); mockOptions.tokens.refresh.mockResolvedValue(null); @@ -562,8 +581,8 @@ describe('OIDCAuthenticationProvider', () => { const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' }; const authorization = `Bearer ${tokenPair.accessToken}`; - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); mockOptions.tokens.refresh.mockResolvedValue(null); @@ -604,7 +623,7 @@ describe('OIDCAuthenticationProvider', () => { await expect(provider.logout(request)).resolves.toEqual(DeauthenticationResult.notHandled()); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); it('redirects to logged out view if state is `null` or does not include access token.', async () => { @@ -617,7 +636,7 @@ describe('OIDCAuthenticationProvider', () => { DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request)) ); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); it('fails if OpenID Connect logout call fails.', async () => { @@ -625,15 +644,22 @@ describe('OIDCAuthenticationProvider', () => { const accessToken = 'x-oidc-token'; const refreshToken = 'x-oidc-refresh-token'; - const failureReason = new Error('Realm is misconfigured!'); - mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ + statusCode: 400, + body: { message: 'Realm is misconfigured!' }, + }) + ); + mockOptions.client.asInternalUser.transport.request.mockRejectedValue(failureReason); await expect( provider.logout(request, { accessToken, refreshToken, realm: 'oidc1' }) ).resolves.toEqual(DeauthenticationResult.failed(failureReason)); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcLogout', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/oidc/logout', body: { token: accessToken, refresh_token: refreshToken }, }); }); @@ -643,14 +669,18 @@ describe('OIDCAuthenticationProvider', () => { const accessToken = 'x-oidc-token'; const refreshToken = 'x-oidc-refresh-token'; - mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: null }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ body: { redirect: null } }) + ); await expect( provider.logout(request, { accessToken, refreshToken, realm: 'oidc1' }) ).resolves.toEqual(DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request))); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcLogout', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/oidc/logout', body: { token: accessToken, refresh_token: refreshToken }, }); }); @@ -660,9 +690,11 @@ describe('OIDCAuthenticationProvider', () => { const accessToken = 'x-oidc-token'; const refreshToken = 'x-oidc-refresh-token'; - mockOptions.client.callAsInternalUser.mockResolvedValue({ - redirect: 'http://fake-idp/logout&id_token_hint=thehint', - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { redirect: 'http://fake-idp/logout&id_token_hint=thehint' }, + }) + ); await expect( provider.logout(request, { accessToken, refreshToken, realm: 'oidc1' }) @@ -670,8 +702,10 @@ describe('OIDCAuthenticationProvider', () => { DeauthenticationResult.redirectTo('http://fake-idp/logout&id_token_hint=thehint') ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcLogout', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/oidc/logout', body: { token: accessToken, refresh_token: refreshToken }, }); }); diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.ts b/x-pack/plugins/security/server/authentication/providers/oidc.ts index c46ea37f144e9f..b89267f44eeeb9 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.ts @@ -248,14 +248,20 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { try { // This operation should be performed on behalf of the user with a privilege that normal // user usually doesn't have `cluster:admin/xpack/security/oidc/authenticate`. - result = await this.options.client.callAsInternalUser('shield.oidcAuthenticate', { - body: { - state: stateOIDCState, - nonce: stateNonce, - redirect_uri: authenticationResponseURI, - realm: this.realm, - }, - }); + // We can replace generic `transport.request` with a dedicated API method call once + // https://github.com/elastic/elasticsearch/issues/67189 is resolved. + result = ( + await this.options.client.asInternalUser.transport.request({ + method: 'POST', + path: '/_security/oidc/authenticate', + body: { + state: stateOIDCState, + nonce: stateNonce, + redirect_uri: authenticationResponseURI, + realm: this.realm, + }, + }) + ).body as any; } catch (err) { this.logger.debug(`Failed to authenticate request via OpenID Connect: ${err.message}`); return AuthenticationResult.failed(err); @@ -289,11 +295,15 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { try { // This operation should be performed on behalf of the user with a privilege that normal // user usually doesn't have `cluster:admin/xpack/security/oidc/prepare`. - const { - state, - nonce, - redirect, - } = await this.options.client.callAsInternalUser('shield.oidcPrepare', { body: params }); + // We can replace generic `transport.request` with a dedicated API method call once + // https://github.com/elastic/elasticsearch/issues/67189 is resolved. + const { state, nonce, redirect } = ( + await this.options.client.asInternalUser.transport.request({ + method: 'POST', + path: '/_security/oidc/prepare', + body: params, + }) + ).body as any; this.logger.debug('Redirecting to OpenID Connect Provider with authentication request.'); return AuthenticationResult.redirectTo( @@ -407,18 +417,17 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { if (state?.accessToken) { try { - const logoutBody = { - body: { - token: state.accessToken, - refresh_token: state.refreshToken, - }, - }; // This operation should be performed on behalf of the user with a privilege that normal // user usually doesn't have `cluster:admin/xpack/security/oidc/logout`. - const { redirect } = await this.options.client.callAsInternalUser( - 'shield.oidcLogout', - logoutBody - ); + // We can replace generic `transport.request` with a dedicated API method call once + // https://github.com/elastic/elasticsearch/issues/67189 is resolved. + const { redirect } = ( + await this.options.client.asInternalUser.transport.request({ + method: 'POST', + path: '/_security/oidc/logout', + body: { token: state.accessToken, refresh_token: state.refreshToken }, + }) + ).body as any; this.logger.debug('User session has been successfully invalidated.'); diff --git a/x-pack/plugins/security/server/authentication/providers/pki.test.ts b/x-pack/plugins/security/server/authentication/providers/pki.test.ts index 88753f8dc2ab1e..d98d6ca4fa0713 100644 --- a/x-pack/plugins/security/server/authentication/providers/pki.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/pki.test.ts @@ -10,18 +10,14 @@ jest.mock('tls'); import { Socket } from 'net'; import { PeerCertificate, TLSSocket } from 'tls'; import Boom from '@hapi/boom'; -import { errors } from 'elasticsearch'; +import { errors } from '@elastic/elasticsearch'; import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import { securityMock } from '../../mocks'; import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions } from './base.mock'; -import { - LegacyElasticsearchErrorHelpers, - ILegacyClusterClient, - KibanaRequest, - ScopeableRequest, -} from '../../../../../../src/core/server'; +import { KibanaRequest, ScopeableRequest } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { PKIAuthenticationProvider } from './pki'; @@ -87,15 +83,14 @@ function getMockSocket({ } function expectAuthenticateCall( - mockClusterClient: jest.Mocked, + mockClusterClient: ReturnType, scopeableRequest: ScopeableRequest ) { expect(mockClusterClient.asScoped).toHaveBeenCalledTimes(1); expect(mockClusterClient.asScoped).toHaveBeenCalledWith(scopeableRequest); const mockScopedClusterClient = mockClusterClient.asScoped.mock.results[0].value; - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expect(mockScopedClusterClient.asCurrentUser.security.authenticate).toHaveBeenCalledTimes(1); } describe('PKIAuthenticationProvider', () => { @@ -125,7 +120,7 @@ describe('PKIAuthenticationProvider', () => { await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled()); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); expectDebugLogs( 'Peer certificate chain: [{"subject":"mock subject(2A:7A:C2:DD)","issuer":"mock issuer","issuerCertType":"object","subjectaltname":"mock subjectaltname","validFrom":"mock valid_from","validTo":"mock valid_to"}]', 'Authentication is not possible since peer certificate was not authorized: Error: mock authorization error.' @@ -139,7 +134,7 @@ describe('PKIAuthenticationProvider', () => { await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled()); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); expectDebugLogs( 'Peer certificate chain: []', 'Authentication is not possible due to missing peer certificate chain.' @@ -159,7 +154,7 @@ describe('PKIAuthenticationProvider', () => { await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled()); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); expectDebugLogs( `Detected incomplete certificate chain with protocol 'TLSv1.3', cannot renegotiate connection.`, 'Peer certificate chain: [{"subject":"mock subject(2A:7A:C2:DD)","issuer":"mock issuer","issuerCertType":"undefined","subjectaltname":"mock subjectaltname","validFrom":"mock valid_from","validTo":"mock valid_to"}]', @@ -181,7 +176,7 @@ describe('PKIAuthenticationProvider', () => { await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled()); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); expectDebugLogs( `Detected incomplete certificate chain with protocol 'TLSv1.2', attempting to renegotiate connection.`, `Failed to renegotiate connection: Error: Oh no!.`, @@ -203,7 +198,7 @@ describe('PKIAuthenticationProvider', () => { await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled()); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); expectDebugLogs( `Detected incomplete certificate chain with protocol 'TLSv1.2', attempting to renegotiate connection.`, 'Peer certificate chain: [{"subject":"mock subject(2A:7A:C2:DD)","issuer":"mock issuer","issuerCertType":"undefined","subjectaltname":"mock subjectaltname","validFrom":"mock valid_from","validTo":"mock valid_to"}]', @@ -231,7 +226,7 @@ describe('PKIAuthenticationProvider', () => { await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled()); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); expectDebugLogs( `Detected incomplete certificate chain with protocol 'TLSv1.2', attempting to renegotiate connection.`, 'Self-signed certificate is detected in certificate chain', @@ -253,10 +248,11 @@ describe('PKIAuthenticationProvider', () => { mockGetPeerCertificate.mockReturnValueOnce(peerCertificate1); const request = httpServerMock.createKibanaRequest({ socket }); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - authentication: user, - access_token: 'access-token', - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { authentication: user, access_token: 'access-token' }, + }) + ); await expect(operation(request)).resolves.toEqual( AuthenticationResult.succeeded( @@ -268,8 +264,10 @@ describe('PKIAuthenticationProvider', () => { ) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/delegate_pki', body: { x509_certificate_chain: [ 'fingerprint:2A:7A:C2:DD:base64', @@ -295,10 +293,11 @@ describe('PKIAuthenticationProvider', () => { const { socket } = getMockSocket({ authorized: true, peerCertificate }); const request = httpServerMock.createKibanaRequest({ socket, headers: {} }); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - authentication: user, - access_token: 'access-token', - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { authentication: user, access_token: 'access-token' }, + }) + ); await expect(operation(request)).resolves.toEqual( AuthenticationResult.succeeded( @@ -310,8 +309,10 @@ describe('PKIAuthenticationProvider', () => { ) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/delegate_pki', body: { x509_certificate_chain: [ 'fingerprint:2A:7A:C2:DD:base64', @@ -330,10 +331,11 @@ describe('PKIAuthenticationProvider', () => { const { socket } = getMockSocket({ authorized: true, peerCertificate }); const request = httpServerMock.createKibanaRequest({ socket, headers: {} }); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - authentication: user, - access_token: 'access-token', - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { authentication: user, access_token: 'access-token' }, + }) + ); await expect(operation(request)).resolves.toEqual( AuthenticationResult.succeeded( @@ -345,8 +347,10 @@ describe('PKIAuthenticationProvider', () => { ) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/delegate_pki', body: { x509_certificate_chain: ['fingerprint:2A:7A:C2:DD:base64'] }, }); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); @@ -359,13 +363,17 @@ describe('PKIAuthenticationProvider', () => { const { socket } = getMockSocket({ authorized: true, peerCertificate }); const request = httpServerMock.createKibanaRequest({ socket, headers: {} }); - const failureReason = LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); - mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 401, body: {} }) + ); + mockOptions.client.asInternalUser.transport.request.mockRejectedValue(failureReason); await expect(operation(request)).resolves.toEqual(AuthenticationResult.failed(failureReason)); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/delegate_pki', body: { x509_certificate_chain: ['fingerprint:2A:7A:C2:DD:base64'] }, }); @@ -390,7 +398,7 @@ describe('PKIAuthenticationProvider', () => { ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); expect(request.headers.authorization).toBe('Bearer some-token'); }); @@ -408,7 +416,7 @@ describe('PKIAuthenticationProvider', () => { ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); expect(request.headers.authorization).toBe('Bearer some-token'); }); @@ -421,7 +429,7 @@ describe('PKIAuthenticationProvider', () => { ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); it('does not exchange peer certificate to access token for Ajax requests.', async () => { @@ -436,7 +444,7 @@ describe('PKIAuthenticationProvider', () => { ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); it('fails with non-401 error if state is available, peer is authorized, but certificate is not available.', async () => { @@ -490,10 +498,11 @@ describe('PKIAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest({ socket }); const state = { accessToken: 'existing-token', peerCertificateFingerprint256: '3A:9A:C5:DD' }; - mockOptions.client.callAsInternalUser.mockResolvedValue({ - authentication: user, - access_token: 'access-token', - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { authentication: user, access_token: 'access-token' }, + }) + ); await expect(provider.authenticate(request, state)).resolves.toEqual( AuthenticationResult.succeeded( @@ -510,8 +519,10 @@ describe('PKIAuthenticationProvider', () => { accessToken: state.accessToken, }); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/delegate_pki', body: { x509_certificate_chain: [ 'fingerprint:2A:7A:C2:DD:base64', @@ -526,15 +537,16 @@ describe('PKIAuthenticationProvider', () => { it('gets a new access token even if existing token is expired.', async () => { const user = mockAuthenticatedUser({ authentication_provider: { type: 'pki', name: 'pki' } }); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - authentication: user, - access_token: 'access-token', - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { authentication: user, access_token: 'access-token' }, + }) + ); const nonAjaxRequest = httpServerMock.createKibanaRequest({ socket: getMockSocket({ @@ -589,8 +601,10 @@ describe('PKIAuthenticationProvider', () => { }) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(3); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(3); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/delegate_pki', body: { x509_certificate_chain: [ 'fingerprint:2A:7A:C2:DD:base64', @@ -598,7 +612,9 @@ describe('PKIAuthenticationProvider', () => { ], }, }); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/delegate_pki', body: { x509_certificate_chain: [ 'fingerprint:3A:7A:C2:DD:base64', @@ -606,7 +622,9 @@ describe('PKIAuthenticationProvider', () => { ], }, }); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/delegate_pki', body: { x509_certificate_chain: [ 'fingerprint:4A:7A:C2:DD:base64', @@ -625,9 +643,9 @@ describe('PKIAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest({ socket }); const state = { accessToken: 'existing-token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); @@ -635,7 +653,7 @@ describe('PKIAuthenticationProvider', () => { AuthenticationResult.failed(Boom.unauthorized()) ); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); expect(request.headers).not.toHaveProperty('authorization'); }); @@ -647,8 +665,10 @@ describe('PKIAuthenticationProvider', () => { const { socket } = getMockSocket({ authorized: true, peerCertificate }); const request = httpServerMock.createKibanaRequest({ socket, headers: {} }); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResolvedValue( + securityMock.createApiResponse({ body: user }) + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(provider.authenticate(request, state)).resolves.toEqual( @@ -660,7 +680,7 @@ describe('PKIAuthenticationProvider', () => { expectAuthenticateCall(mockOptions.client, { headers: { authorization: 'Bearer token' } }); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); expect(request.headers).not.toHaveProperty('authorization'); }); @@ -671,9 +691,12 @@ describe('PKIAuthenticationProvider', () => { const { socket } = getMockSocket({ authorized: true, peerCertificate }); const request = httpServerMock.createKibanaRequest({ socket, headers: {} }); - const failureReason = new errors.ServiceUnavailable(); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + const failureReason = new errors.ConnectionError( + 'unavailable', + securityMock.createApiResponse({ statusCode: 503, body: {} }) + ); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue(failureReason); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(provider.authenticate(request, state)).resolves.toEqual( diff --git a/x-pack/plugins/security/server/authentication/providers/pki.ts b/x-pack/plugins/security/server/authentication/providers/pki.ts index 3171c5ff24abe1..a5dc870bf9c841 100644 --- a/x-pack/plugins/security/server/authentication/providers/pki.ts +++ b/x-pack/plugins/security/server/authentication/providers/pki.ts @@ -272,9 +272,15 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { let result: { access_token: string; authentication: AuthenticationInfo }; try { - result = await this.options.client.callAsInternalUser('shield.delegatePKI', { - body: { x509_certificate_chain: certificateChain }, - }); + // We can replace generic `transport.request` with a dedicated API method call once + // https://github.com/elastic/elasticsearch/issues/67189 is resolved. + result = ( + await this.options.client.asInternalUser.transport.request({ + method: 'POST', + path: '/_security/delegate_pki', + body: { x509_certificate_chain: certificateChain }, + }) + ).body as any; } catch (err) { this.logger.debug( `Failed to exchange peer certificate chain to an access token: ${err.message}` @@ -298,7 +304,8 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { } /** - * Obtains the peer certificate chain. Starts from the leaf peer certificate and iterates up to the top-most available certificate + * Obtains the peer certificate chain as an ordered array of base64-encoded (Section 4 of RFC4648 - not base64url-encoded) + * DER PKIX certificate values. Starts from the leaf peer certificate and iterates up to the top-most available certificate * authority using `issuerCertificate` certificate property. THe iteration is stopped only when we detect circular reference * (root/self-signed certificate) or when `issuerCertificate` isn't available (null or empty object). Automatically attempts to * renegotiate the TLS connection once if the peer certificate chain is incomplete. diff --git a/x-pack/plugins/security/server/authentication/providers/saml.test.ts b/x-pack/plugins/security/server/authentication/providers/saml.test.ts index 5cba017e4916b2..48334358bb0014 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.test.ts @@ -5,15 +5,13 @@ */ import Boom from '@hapi/boom'; +import { errors } from '@elastic/elasticsearch'; import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import { securityMock } from '../../mocks'; import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions } from './base.mock'; -import { - LegacyElasticsearchErrorHelpers, - ILegacyScopedClusterClient, -} from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { SAMLAuthenticationProvider, SAMLLogin } from './saml'; @@ -23,19 +21,17 @@ describe('SAMLAuthenticationProvider', () => { let provider: SAMLAuthenticationProvider; let mockOptions: MockAuthenticationProviderOptions; let mockUser: AuthenticatedUser; - let mockScopedClusterClient: jest.Mocked; + let mockScopedClusterClient: ReturnType< + typeof elasticsearchServiceMock.createScopedClusterClient + >; beforeEach(() => { mockOptions = mockAuthenticationProviderOptions({ name: 'saml' }); - mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); mockUser = mockAuthenticatedUser({ authentication_provider: { type: 'saml', name: 'saml' } }); - mockScopedClusterClient.callAsCurrentUser.mockImplementation(async (method) => { - if (method === 'shield.authenticate') { - return mockUser; - } - - throw new Error(`Unexpected call to ${method}!`); - }); + mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResolvedValue( + securityMock.createApiResponse({ body: mockUser }) + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); provider = new SAMLAuthenticationProvider(mockOptions, { @@ -61,11 +57,15 @@ describe('SAMLAuthenticationProvider', () => { it('gets token and redirects user to requested URL if SAML Response is valid.', async () => { const request = httpServerMock.createKibanaRequest(); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - access_token: 'some-token', - refresh_token: 'some-refresh-token', - authentication: mockUser, - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { + access_token: 'some-token', + refresh_token: 'some-refresh-token', + authentication: mockUser, + }, + }) + ); await expect( provider.login( @@ -88,20 +88,25 @@ describe('SAMLAuthenticationProvider', () => { }) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( - 'shield.samlAuthenticate', - { body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' } } - ); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/authenticate', + body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' }, + }); }); it('gets token and redirects user to the requested URL if SAML Response is valid ignoring Relay State.', async () => { const request = httpServerMock.createKibanaRequest(); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - access_token: 'some-token', - refresh_token: 'some-refresh-token', - authentication: mockUser, - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { + access_token: 'some-token', + refresh_token: 'some-refresh-token', + authentication: mockUser, + }, + }) + ); provider = new SAMLAuthenticationProvider(mockOptions, { realm: 'test-realm', @@ -132,10 +137,11 @@ describe('SAMLAuthenticationProvider', () => { }) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( - 'shield.samlAuthenticate', - { body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' } } - ); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/authenticate', + body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' }, + }); }); it('fails if SAML Response payload is presented but state does not contain SAML Request token.', async () => { @@ -153,7 +159,7 @@ describe('SAMLAuthenticationProvider', () => { ) ); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); it('fails if realm from state is different from the realm provider is configured with.', async () => { @@ -173,17 +179,21 @@ describe('SAMLAuthenticationProvider', () => { ) ); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); it('redirects to the default location if state contains empty redirect URL.', async () => { const request = httpServerMock.createKibanaRequest(); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - access_token: 'user-initiated-login-token', - refresh_token: 'user-initiated-login-refresh-token', - authentication: mockUser, - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { + access_token: 'user-initiated-login-token', + refresh_token: 'user-initiated-login-refresh-token', + authentication: mockUser, + }, + }) + ); await expect( provider.login( @@ -202,20 +212,25 @@ describe('SAMLAuthenticationProvider', () => { }) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( - 'shield.samlAuthenticate', - { body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' } } - ); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/authenticate', + body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' }, + }); }); it('redirects to the default location if state contains empty redirect URL ignoring Relay State.', async () => { const request = httpServerMock.createKibanaRequest(); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - access_token: 'user-initiated-login-token', - refresh_token: 'user-initiated-login-refresh-token', - authentication: mockUser, - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { + access_token: 'user-initiated-login-token', + refresh_token: 'user-initiated-login-refresh-token', + authentication: mockUser, + }, + }) + ); provider = new SAMLAuthenticationProvider(mockOptions, { realm: 'test-realm', @@ -242,20 +257,25 @@ describe('SAMLAuthenticationProvider', () => { }) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( - 'shield.samlAuthenticate', - { body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' } } - ); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/authenticate', + body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' }, + }); }); it('redirects to the default location if state is not presented.', async () => { const request = httpServerMock.createKibanaRequest(); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - access_token: 'idp-initiated-login-token', - refresh_token: 'idp-initiated-login-refresh-token', - authentication: mockUser, - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { + access_token: 'idp-initiated-login-token', + refresh_token: 'idp-initiated-login-refresh-token', + authentication: mockUser, + }, + }) + ); await expect( provider.login(request, { @@ -273,17 +293,20 @@ describe('SAMLAuthenticationProvider', () => { }) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( - 'shield.samlAuthenticate', - { body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' } } - ); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/authenticate', + body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' }, + }); }); it('fails if SAML Response is rejected.', async () => { const request = httpServerMock.createKibanaRequest(); - const failureReason = new Error('SAML response is stale!'); - mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 503, body: {} }) + ); + mockOptions.client.asInternalUser.transport.request.mockRejectedValue(failureReason); await expect( provider.login( @@ -297,22 +320,27 @@ describe('SAMLAuthenticationProvider', () => { ) ).resolves.toEqual(AuthenticationResult.failed(failureReason)); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( - 'shield.samlAuthenticate', - { body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' } } - ); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/authenticate', + body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' }, + }); }); describe('IdP initiated login', () => { beforeEach(() => { mockOptions.basePath.get.mockReturnValue(mockOptions.basePath.serverBasePath); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - username: 'user', - access_token: 'valid-token', - refresh_token: 'valid-refresh-token', - authentication: mockUser, - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { + username: 'user', + access_token: 'valid-token', + refresh_token: 'valid-refresh-token', + authentication: mockUser, + }, + }) + ); provider = new SAMLAuthenticationProvider(mockOptions, { realm: 'test-realm', @@ -428,8 +456,10 @@ describe('SAMLAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); const authorization = 'Bearer some-valid-token'; - const failureReason = new Error('SAML response is invalid!'); - mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 503, body: {} }) + ); + mockOptions.client.asInternalUser.transport.request.mockRejectedValue(failureReason); await expect( provider.login( @@ -444,12 +474,11 @@ describe('SAMLAuthenticationProvider', () => { ).resolves.toEqual(AuthenticationResult.notHandled()); expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( - 'shield.samlAuthenticate', - { - body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' }, - } - ); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/authenticate', + body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' }, + }); }); it('fails if fails to invalidate existing access/refresh tokens.', async () => { @@ -461,12 +490,16 @@ describe('SAMLAuthenticationProvider', () => { }; const authorization = `Bearer ${state.accessToken}`; - mockOptions.client.callAsInternalUser.mockResolvedValue({ - username: 'user', - access_token: 'new-valid-token', - refresh_token: 'new-valid-refresh-token', - authentication: mockUser, - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { + username: 'user', + access_token: 'new-valid-token', + refresh_token: 'new-valid-refresh-token', + authentication: mockUser, + }, + }) + ); const failureReason = new Error('Failed to invalidate token!'); mockOptions.tokens.invalidate.mockRejectedValue(failureReason); @@ -480,12 +513,11 @@ describe('SAMLAuthenticationProvider', () => { ).resolves.toEqual(AuthenticationResult.failed(failureReason)); expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( - 'shield.samlAuthenticate', - { - body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' }, - } - ); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/authenticate', + body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' }, + }); expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ @@ -498,14 +530,20 @@ describe('SAMLAuthenticationProvider', () => { [ 'current session is valid', Promise.resolve( - mockAuthenticatedUser({ authentication_provider: { type: 'saml', name: 'saml' } }) + securityMock.createApiResponse({ + body: mockAuthenticatedUser({ + authentication_provider: { type: 'saml', name: 'saml' }, + }), + }) ), ], [ 'current session is is expired', - Promise.reject(LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error())), + Promise.reject( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) + ), ], - ] as Array<[string, Promise]>) { + ] as Array<[string, any]>) { it(`redirects to the home page if ${description}.`, async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); const state = { @@ -516,19 +554,19 @@ describe('SAMLAuthenticationProvider', () => { const authorization = `Bearer ${state.accessToken}`; // The first call is made using tokens from existing session. - mockScopedClusterClient.callAsCurrentUser.mockImplementationOnce(() => response); - // The second call is made using new tokens. - mockScopedClusterClient.callAsCurrentUser.mockImplementationOnce(() => - Promise.resolve(mockUser) + mockScopedClusterClient.asCurrentUser.security.authenticate.mockImplementationOnce( + () => response + ); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { + username: 'user', + access_token: 'new-valid-token', + refresh_token: 'new-valid-refresh-token', + authentication: mockUser, + }, + }) ); - - mockOptions.client.callAsInternalUser.mockResolvedValue({ - username: 'user', - access_token: 'new-valid-token', - refresh_token: 'new-valid-refresh-token', - authentication: mockUser, - }); - mockOptions.tokens.invalidate.mockResolvedValue(undefined); await expect( @@ -549,12 +587,11 @@ describe('SAMLAuthenticationProvider', () => { ); expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( - 'shield.samlAuthenticate', - { - body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' }, - } - ); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/authenticate', + body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' }, + }); expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ @@ -573,13 +610,19 @@ describe('SAMLAuthenticationProvider', () => { const authorization = `Bearer ${state.accessToken}`; // The first call is made using tokens from existing session. - mockScopedClusterClient.callAsCurrentUser.mockImplementationOnce(() => response); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - username: 'user', - access_token: 'new-valid-token', - refresh_token: 'new-valid-refresh-token', - authentication: mockUser, - }); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockImplementationOnce( + () => response + ); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { + username: 'user', + access_token: 'new-valid-token', + refresh_token: 'new-valid-refresh-token', + authentication: mockUser, + }, + }) + ); mockOptions.tokens.invalidate.mockResolvedValue(undefined); @@ -610,12 +653,11 @@ describe('SAMLAuthenticationProvider', () => { ); expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( - 'shield.samlAuthenticate', - { - body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' }, - } - ); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/authenticate', + body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' }, + }); expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ @@ -641,16 +683,20 @@ describe('SAMLAuthenticationProvider', () => { ) ); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); it('redirects requests to the IdP remembering redirect URL with existing state.', async () => { const request = httpServerMock.createKibanaRequest(); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - id: 'some-request-id', - redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { + id: 'some-request-id', + redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', + }, + }) + ); await expect( provider.login( @@ -674,7 +720,9 @@ describe('SAMLAuthenticationProvider', () => { ) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/prepare', body: { realm: 'test-realm' }, }); @@ -684,10 +732,14 @@ describe('SAMLAuthenticationProvider', () => { it('redirects requests to the IdP remembering redirect URL without state.', async () => { const request = httpServerMock.createKibanaRequest(); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - id: 'some-request-id', - redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { + id: 'some-request-id', + redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', + }, + }) + ); await expect( provider.login( @@ -711,7 +763,9 @@ describe('SAMLAuthenticationProvider', () => { ) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/prepare', body: { realm: 'test-realm' }, }); @@ -721,8 +775,10 @@ describe('SAMLAuthenticationProvider', () => { it('fails if SAML request preparation fails.', async () => { const request = httpServerMock.createKibanaRequest(); - const failureReason = new Error('Realm is misconfigured!'); - mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 401, body: {} }) + ); + mockOptions.client.asInternalUser.transport.request.mockRejectedValue(failureReason); await expect( provider.login( @@ -735,7 +791,9 @@ describe('SAMLAuthenticationProvider', () => { ) ).resolves.toEqual(AuthenticationResult.failed(failureReason)); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/prepare', body: { realm: 'test-realm' }, }); }); @@ -791,11 +849,6 @@ describe('SAMLAuthenticationProvider', () => { it('redirects non-AJAX request that can not be authenticated to the "capture URL" page.', async () => { const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path' }); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - id: 'some-request-id', - redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', - }); - await expect(provider.authenticate(request)).resolves.toEqual( AuthenticationResult.redirectTo( '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path&providerType=saml&providerName=saml', @@ -803,7 +856,7 @@ describe('SAMLAuthenticationProvider', () => { ) ); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); it('succeeds if state contains a valid token.', async () => { @@ -833,11 +886,13 @@ describe('SAMLAuthenticationProvider', () => { }; const authorization = `Bearer ${state.accessToken}`; - const failureReason = { statusCode: 500, message: 'Token is not valid!' }; - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 500, body: {} }) + ); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue(failureReason); await expect(provider.authenticate(request, state)).resolves.toEqual( - AuthenticationResult.failed(failureReason as any) + AuthenticationResult.failed(failureReason) ); expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); @@ -853,8 +908,8 @@ describe('SAMLAuthenticationProvider', () => { realm: 'test-realm', }; - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); mockOptions.tokens.refresh.mockResolvedValue({ @@ -889,8 +944,8 @@ describe('SAMLAuthenticationProvider', () => { }; const authorization = `Bearer ${state.accessToken}`; - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); const refreshFailureReason = { @@ -920,8 +975,8 @@ describe('SAMLAuthenticationProvider', () => { }; const authorization = `Bearer ${state.accessToken}`; - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); mockOptions.tokens.refresh.mockResolvedValue(null); @@ -949,8 +1004,8 @@ describe('SAMLAuthenticationProvider', () => { }; const authorization = `Bearer ${state.accessToken}`; - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); mockOptions.tokens.refresh.mockResolvedValue(null); @@ -976,8 +1031,8 @@ describe('SAMLAuthenticationProvider', () => { }; const authorization = `Bearer ${state.accessToken}`; - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); mockOptions.tokens.refresh.mockResolvedValue(null); @@ -994,7 +1049,7 @@ describe('SAMLAuthenticationProvider', () => { expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); it('fails if realm from state is different from the realm provider is configured with.', async () => { @@ -1015,7 +1070,7 @@ describe('SAMLAuthenticationProvider', () => { await expect(provider.logout(request)).resolves.toEqual(DeauthenticationResult.notHandled()); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); it('redirects to logged out view if state is `null` or does not include access token.', async () => { @@ -1028,7 +1083,7 @@ describe('SAMLAuthenticationProvider', () => { DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request)) ); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); it('fails if SAML logout call fails.', async () => { @@ -1036,8 +1091,10 @@ describe('SAMLAuthenticationProvider', () => { const accessToken = 'x-saml-token'; const refreshToken = 'x-saml-refresh-token'; - const failureReason = new Error('Realm is misconfigured!'); - mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 500, body: {} }) + ); + mockOptions.client.asInternalUser.transport.request.mockRejectedValue(failureReason); await expect( provider.logout(request, { @@ -1047,8 +1104,10 @@ describe('SAMLAuthenticationProvider', () => { }) ).resolves.toEqual(DeauthenticationResult.failed(failureReason)); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlLogout', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/logout', body: { token: accessToken, refresh_token: refreshToken }, }); }); @@ -1056,15 +1115,19 @@ describe('SAMLAuthenticationProvider', () => { it('fails if SAML invalidate call fails.', async () => { const request = httpServerMock.createKibanaRequest({ query: { SAMLRequest: 'xxx yyy' } }); - const failureReason = new Error('Realm is misconfigured!'); - mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 500, body: {} }) + ); + mockOptions.client.asInternalUser.transport.request.mockRejectedValue(failureReason); await expect(provider.logout(request)).resolves.toEqual( DeauthenticationResult.failed(failureReason) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlInvalidate', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/invalidate', body: { queryString: 'SAMLRequest=xxx%20yyy', realm: 'test-realm' }, }); }); @@ -1074,7 +1137,9 @@ describe('SAMLAuthenticationProvider', () => { const accessToken = 'x-saml-token'; const refreshToken = 'x-saml-refresh-token'; - mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: null }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ body: { redirect: null } }) + ); await expect( provider.logout(request, { @@ -1084,8 +1149,10 @@ describe('SAMLAuthenticationProvider', () => { }) ).resolves.toEqual(DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request))); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlLogout', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/logout', body: { token: accessToken, refresh_token: refreshToken }, }); }); @@ -1095,7 +1162,9 @@ describe('SAMLAuthenticationProvider', () => { const accessToken = 'x-saml-token'; const refreshToken = 'x-saml-refresh-token'; - mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: undefined }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ body: { redirect: undefined } }) + ); await expect( provider.logout(request, { @@ -1105,8 +1174,10 @@ describe('SAMLAuthenticationProvider', () => { }) ).resolves.toEqual(DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request))); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlLogout', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/logout', body: { token: accessToken, refresh_token: refreshToken }, }); }); @@ -1118,7 +1189,9 @@ describe('SAMLAuthenticationProvider', () => { const accessToken = 'x-saml-token'; const refreshToken = 'x-saml-refresh-token'; - mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: null }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ body: { redirect: null } }) + ); await expect( provider.logout(request, { @@ -1128,8 +1201,10 @@ describe('SAMLAuthenticationProvider', () => { }) ).resolves.toEqual(DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request))); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlLogout', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/logout', body: { token: accessToken, refresh_token: refreshToken }, }); }); @@ -1137,7 +1212,9 @@ describe('SAMLAuthenticationProvider', () => { it('relies on SAML invalidate call even if access token is presented.', async () => { const request = httpServerMock.createKibanaRequest({ query: { SAMLRequest: 'xxx yyy' } }); - mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: null }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ body: { redirect: null } }) + ); await expect( provider.logout(request, { @@ -1147,8 +1224,10 @@ describe('SAMLAuthenticationProvider', () => { }) ).resolves.toEqual(DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request))); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlInvalidate', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/invalidate', body: { queryString: 'SAMLRequest=xxx%20yyy', realm: 'test-realm' }, }); }); @@ -1156,14 +1235,18 @@ describe('SAMLAuthenticationProvider', () => { it('redirects to `loggedOut` URL if `redirect` field in SAML invalidate response is null.', async () => { const request = httpServerMock.createKibanaRequest({ query: { SAMLRequest: 'xxx yyy' } }); - mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: null }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ body: { redirect: null } }) + ); await expect(provider.logout(request)).resolves.toEqual( DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request)) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlInvalidate', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/invalidate', body: { queryString: 'SAMLRequest=xxx%20yyy', realm: 'test-realm' }, }); }); @@ -1171,14 +1254,18 @@ describe('SAMLAuthenticationProvider', () => { it('redirects to `loggedOut` URL if `redirect` field in SAML invalidate response is not defined.', async () => { const request = httpServerMock.createKibanaRequest({ query: { SAMLRequest: 'xxx yyy' } }); - mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: undefined }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ body: { redirect: undefined } }) + ); await expect(provider.logout(request)).resolves.toEqual( DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request)) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlInvalidate', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/invalidate', body: { queryString: 'SAMLRequest=xxx%20yyy', realm: 'test-realm' }, }); }); @@ -1190,7 +1277,7 @@ describe('SAMLAuthenticationProvider', () => { DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request)) ); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); it('redirects user to the IdP if SLO is supported by IdP in case of SP initiated logout.', async () => { @@ -1198,9 +1285,11 @@ describe('SAMLAuthenticationProvider', () => { const accessToken = 'x-saml-token'; const refreshToken = 'x-saml-refresh-token'; - mockOptions.client.callAsInternalUser.mockResolvedValue({ - redirect: 'http://fake-idp/SLO?SAMLRequest=7zlH37H', - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { redirect: 'http://fake-idp/SLO?SAMLRequest=7zlH37H' }, + }) + ); await expect( provider.logout(request, { @@ -1212,15 +1301,17 @@ describe('SAMLAuthenticationProvider', () => { DeauthenticationResult.redirectTo('http://fake-idp/SLO?SAMLRequest=7zlH37H') ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); }); it('redirects user to the IdP if SLO is supported by IdP in case of IdP initiated logout.', async () => { const request = httpServerMock.createKibanaRequest({ query: { SAMLRequest: 'xxx yyy' } }); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - redirect: 'http://fake-idp/SLO?SAMLRequest=7zlH37H', - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { redirect: 'http://fake-idp/SLO?SAMLRequest=7zlH37H' }, + }) + ); await expect( provider.logout(request, { @@ -1232,7 +1323,7 @@ describe('SAMLAuthenticationProvider', () => { DeauthenticationResult.redirectTo('http://fake-idp/SLO?SAMLRequest=7zlH37H') ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); }); }); diff --git a/x-pack/plugins/security/server/authentication/providers/saml.ts b/x-pack/plugins/security/server/authentication/providers/saml.ts index 34639a849d354d..58792727de733e 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.ts @@ -343,13 +343,19 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { try { // This operation should be performed on behalf of the user with a privilege that normal // user usually doesn't have `cluster:admin/xpack/security/saml/authenticate`. - result = await this.options.client.callAsInternalUser('shield.samlAuthenticate', { - body: { - ids: !isIdPInitiatedLogin ? [stateRequestId] : [], - content: samlResponse, - realm: this.realm, - }, - }); + // We can replace generic `transport.request` with a dedicated API method call once + // https://github.com/elastic/elasticsearch/issues/67189 is resolved. + result = ( + await this.options.client.asInternalUser.transport.request({ + method: 'POST', + path: '/_security/saml/authenticate', + body: { + ids: !isIdPInitiatedLogin ? [stateRequestId] : [], + content: samlResponse, + realm: this.realm, + }, + }) + ).body as any; } catch (err) { this.logger.debug(`Failed to log in with SAML response: ${err.message}`); @@ -541,12 +547,15 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { try { // This operation should be performed on behalf of the user with a privilege that normal // user usually doesn't have `cluster:admin/xpack/security/saml/prepare`. - const { id: requestId, redirect } = await this.options.client.callAsInternalUser( - 'shield.samlPrepare', - { + // We can replace generic `transport.request` with a dedicated API method call once + // https://github.com/elastic/elasticsearch/issues/67189 is resolved. + const { id: requestId, redirect } = ( + await this.options.client.asInternalUser.transport.request({ + method: 'POST', + path: '/_security/saml/prepare', body: { realm: this.realm }, - } - ); + }) + ).body as any; this.logger.debug('Redirecting to Identity Provider with SAML request.'); @@ -570,9 +579,15 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { // This operation should be performed on behalf of the user with a privilege that normal // user usually doesn't have `cluster:admin/xpack/security/saml/logout`. - const { redirect } = await this.options.client.callAsInternalUser('shield.samlLogout', { - body: { token: accessToken, refresh_token: refreshToken }, - }); + // We can replace generic `transport.request` with a dedicated API method call once + // https://github.com/elastic/elasticsearch/issues/67189 is resolved. + const { redirect } = ( + await this.options.client.asInternalUser.transport.request({ + method: 'POST', + path: '/_security/saml/logout', + body: { token: accessToken, refresh_token: refreshToken }, + }) + ).body as any; this.logger.debug('User session has been successfully invalidated.'); @@ -589,13 +604,19 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { // This operation should be performed on behalf of the user with a privilege that normal // user usually doesn't have `cluster:admin/xpack/security/saml/invalidate`. - const { redirect } = await this.options.client.callAsInternalUser('shield.samlInvalidate', { - // Elasticsearch expects `queryString` without leading `?`, so we should strip it with `slice`. - body: { - queryString: request.url.search ? request.url.search.slice(1) : '', - realm: this.realm, - }, - }); + // We can replace generic `transport.request` with a dedicated API method call once + // https://github.com/elastic/elasticsearch/issues/67189 is resolved. + const { redirect } = ( + await this.options.client.asInternalUser.transport.request({ + method: 'POST', + path: '/_security/saml/invalidate', + // Elasticsearch expects `queryString` without leading `?`, so we should strip it with `slice`. + body: { + queryString: request.url.search ? request.url.search.slice(1) : '', + realm: this.realm, + }, + }) + ).body as any; this.logger.debug('User session has been successfully invalidated.'); diff --git a/x-pack/plugins/security/server/authentication/providers/token.test.ts b/x-pack/plugins/security/server/authentication/providers/token.test.ts index 5a600461ef4675..ad100ac5be893b 100644 --- a/x-pack/plugins/security/server/authentication/providers/token.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.test.ts @@ -5,31 +5,27 @@ */ import Boom from '@hapi/boom'; -import { errors } from 'elasticsearch'; +import { errors } from '@elastic/elasticsearch'; import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import { securityMock } from '../../mocks'; import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions } from './base.mock'; -import { - LegacyElasticsearchErrorHelpers, - ILegacyClusterClient, - ScopeableRequest, -} from '../../../../../../src/core/server'; +import { ScopeableRequest } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { TokenAuthenticationProvider } from './token'; function expectAuthenticateCall( - mockClusterClient: jest.Mocked, + mockClusterClient: ReturnType, scopeableRequest: ScopeableRequest ) { expect(mockClusterClient.asScoped).toHaveBeenCalledTimes(1); expect(mockClusterClient.asScoped).toHaveBeenCalledWith(scopeableRequest); const mockScopedClusterClient = mockClusterClient.asScoped.mock.results[0].value; - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expect(mockScopedClusterClient.asCurrentUser.security.authenticate).toHaveBeenCalledTimes(1); } describe('TokenAuthenticationProvider', () => { @@ -51,11 +47,15 @@ describe('TokenAuthenticationProvider', () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; const authorization = `Bearer ${tokenPair.accessToken}`; - mockOptions.client.callAsInternalUser.mockResolvedValue({ - access_token: tokenPair.accessToken, - refresh_token: tokenPair.refreshToken, - authentication: user, - }); + mockOptions.client.asInternalUser.security.getToken.mockResolvedValue( + securityMock.createApiResponse({ + body: { + access_token: tokenPair.accessToken, + refresh_token: tokenPair.refreshToken, + authentication: user, + }, + }) + ); await expect(provider.login(request, credentials)).resolves.toEqual( AuthenticationResult.succeeded( @@ -65,8 +65,8 @@ describe('TokenAuthenticationProvider', () => { ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { + expect(mockOptions.client.asInternalUser.security.getToken).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.security.getToken).toHaveBeenCalledWith({ body: { grant_type: 'password', ...credentials }, }); }); @@ -75,17 +75,18 @@ describe('TokenAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest(); const credentials = { username: 'user', password: 'password' }; - const authenticationError = new Error('Invalid credentials'); - mockOptions.client.callAsInternalUser.mockRejectedValue(authenticationError); + const authenticationError = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 500, body: {} }) + ); + mockOptions.client.asInternalUser.security.getToken.mockRejectedValue(authenticationError); await expect(provider.login(request, credentials)).resolves.toEqual( AuthenticationResult.failed(authenticationError) ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { + expect(mockOptions.client.asInternalUser.security.getToken).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.security.getToken).toHaveBeenCalledWith({ body: { grant_type: 'password', ...credentials }, }); @@ -158,8 +159,10 @@ describe('TokenAuthenticationProvider', () => { const user = mockAuthenticatedUser(); const authorization = `Bearer ${tokenPair.accessToken}`; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResolvedValue( + securityMock.createApiResponse({ body: user }) + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( @@ -179,9 +182,9 @@ describe('TokenAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); @@ -212,9 +215,13 @@ describe('TokenAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); const authorization = `Bearer ${tokenPair.accessToken}`; - const authenticationError = new errors.InternalServerError('something went wrong'); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(authenticationError); + const authenticationError = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 500, body: {} }) + ); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + authenticationError + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( @@ -231,13 +238,15 @@ describe('TokenAuthenticationProvider', () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; const authorization = `Bearer ${tokenPair.accessToken}`; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - const refreshError = new errors.InternalServerError('failed to refresh token'); + const refreshError = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 500, body: {} }) + ); mockOptions.tokens.refresh.mockRejectedValue(refreshError); await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( @@ -257,9 +266,9 @@ describe('TokenAuthenticationProvider', () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; const authorization = `Bearer ${tokenPair.accessToken}`; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); @@ -288,9 +297,9 @@ describe('TokenAuthenticationProvider', () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; const authorization = `Bearer ${tokenPair.accessToken}`; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); @@ -319,9 +328,9 @@ describe('TokenAuthenticationProvider', () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; const authorization = `Bearer ${tokenPair.accessToken}`; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); diff --git a/x-pack/plugins/security/server/authentication/providers/token.ts b/x-pack/plugins/security/server/authentication/providers/token.ts index 67c2d244e75a29..3afbc25122a26b 100644 --- a/x-pack/plugins/security/server/authentication/providers/token.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.ts @@ -7,6 +7,8 @@ import Boom from '@hapi/boom'; import { KibanaRequest } from '../../../../../../src/core/server'; import { NEXT_URL_QUERY_STRING_PARAMETER } from '../../../common/constants'; +import { AuthenticationInfo } from '../../elasticsearch'; +import { getDetailedErrorMessage } from '../../errors'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { canRedirectRequest } from '../can_redirect_request'; @@ -65,9 +67,13 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { access_token: accessToken, refresh_token: refreshToken, authentication: authenticationInfo, - } = await this.options.client.callAsInternalUser('shield.getAccessToken', { - body: { grant_type: 'password', username, password }, - }); + } = ( + await this.options.client.asInternalUser.security.getToken<{ + access_token: string; + refresh_token: string; + authentication: AuthenticationInfo; + }>({ body: { grant_type: 'password', username, password } }) + ).body; this.logger.debug('Get token API request to Elasticsearch successful'); return AuthenticationResult.succeeded( @@ -80,7 +86,7 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { } ); } catch (err) { - this.logger.debug(`Failed to perform a login: ${err.message}`); + this.logger.debug(`Failed to perform a login: ${getDetailedErrorMessage(err)}`); return AuthenticationResult.failed(err); } } diff --git a/x-pack/plugins/security/server/authentication/tokens.test.ts b/x-pack/plugins/security/server/authentication/tokens.test.ts index 18fdcf8608d29d..eb439d74a46d00 100644 --- a/x-pack/plugins/security/server/authentication/tokens.test.ts +++ b/x-pack/plugins/security/server/authentication/tokens.test.ts @@ -4,25 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import { errors } from 'elasticsearch'; +import { errors } from '@elastic/elasticsearch'; +import { DeeplyMockedKeys } from '@kbn/utility-types/jest'; import { elasticsearchServiceMock, loggingSystemMock } from '../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; +import { securityMock } from '../mocks'; -import { - ILegacyClusterClient, - LegacyElasticsearchErrorHelpers, -} from '../../../../../src/core/server'; +import { ElasticsearchClient } from '../../../../../src/core/server'; import { Tokens } from './tokens'; describe('Tokens', () => { let tokens: Tokens; - let mockClusterClient: jest.Mocked; + let mockElasticsearchClient: DeeplyMockedKeys; beforeEach(() => { - mockClusterClient = elasticsearchServiceMock.createLegacyClusterClient(); + mockElasticsearchClient = elasticsearchServiceMock.createElasticsearchClient(); const tokensOptions = { - client: mockClusterClient, + client: mockElasticsearchClient, logger: loggingSystemMock.create().get(), }; @@ -33,9 +32,24 @@ describe('Tokens', () => { const nonExpirationErrors = [ {}, new Error(), - new errors.InternalServerError(), - new errors.Forbidden(), + new errors.NoLivingConnectionsError( + 'Server is not available', + securityMock.createApiResponse({ body: {} }) + ), + new errors.ResponseError( + securityMock.createApiResponse({ + statusCode: 403, + body: { error: { reason: 'forbidden' } }, + }) + ), { statusCode: 500, body: { error: { reason: 'some unknown reason' } } }, + new errors.NoLivingConnectionsError( + 'Server is not available', + securityMock.createApiResponse({ + statusCode: 500, + body: { error: { reason: 'some unknown reason' } }, + }) + ), ]; for (const error of nonExpirationErrors) { expect(Tokens.isAccessTokenExpiredError(error)).toBe(false); @@ -43,8 +57,10 @@ describe('Tokens', () => { const expirationErrors = [ { statusCode: 401 }, - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()), - new errors.AuthenticationException(), + securityMock.createApiResponse({ + statusCode: 401, + body: { error: { reason: 'unauthenticated' } }, + }), ]; for (const error of expirationErrors) { expect(Tokens.isAccessTokenExpiredError(error)).toBe(true); @@ -55,25 +71,30 @@ describe('Tokens', () => { const refreshToken = 'some-refresh-token'; it('throws if API call fails with unknown reason', async () => { - const refreshFailureReason = new errors.ServiceUnavailable('Server is not available'); - mockClusterClient.callAsInternalUser.mockRejectedValue(refreshFailureReason); + const refreshFailureReason = new errors.NoLivingConnectionsError( + 'Server is not available', + securityMock.createApiResponse({ body: {} }) + ); + mockElasticsearchClient.security.getToken.mockRejectedValue(refreshFailureReason); await expect(tokens.refresh(refreshToken)).rejects.toBe(refreshFailureReason); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { + expect(mockElasticsearchClient.security.getToken).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.security.getToken).toHaveBeenCalledWith({ body: { grant_type: 'refresh_token', refresh_token: refreshToken }, }); }); it('returns `null` if refresh token is not valid', async () => { - const refreshFailureReason = new errors.BadRequest(); - mockClusterClient.callAsInternalUser.mockRejectedValue(refreshFailureReason); + const refreshFailureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 400, body: {} }) + ); + mockElasticsearchClient.security.getToken.mockRejectedValue(refreshFailureReason); await expect(tokens.refresh(refreshToken)).resolves.toBe(null); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { + expect(mockElasticsearchClient.security.getToken).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.security.getToken).toHaveBeenCalledWith({ body: { grant_type: 'refresh_token', refresh_token: refreshToken }, }); }); @@ -81,19 +102,23 @@ describe('Tokens', () => { it('returns token pair if refresh API call succeeds', async () => { const authenticationInfo = mockAuthenticatedUser(); const tokenPair = { accessToken: 'access-token', refreshToken: 'refresh-token' }; - mockClusterClient.callAsInternalUser.mockResolvedValue({ - access_token: tokenPair.accessToken, - refresh_token: tokenPair.refreshToken, - authentication: authenticationInfo, - }); + mockElasticsearchClient.security.getToken.mockResolvedValue( + securityMock.createApiResponse({ + body: { + access_token: tokenPair.accessToken, + refresh_token: tokenPair.refreshToken, + authentication: authenticationInfo, + }, + }) + ); await expect(tokens.refresh(refreshToken)).resolves.toEqual({ authenticationInfo, ...tokenPair, }); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { + expect(mockElasticsearchClient.security.getToken).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.security.getToken).toHaveBeenCalledWith({ body: { grant_type: 'refresh_token', refresh_token: refreshToken }, }); }); @@ -101,158 +126,165 @@ describe('Tokens', () => { describe('invalidate()', () => { for (const [description, failureReason] of [ - ['an unknown error', new Error('failed to delete token')], - ['a 404 error without body', { statusCode: 404 }], + [ + 'an unknown error', + new errors.ResponseError( + securityMock.createApiResponse( + securityMock.createApiResponse({ body: { message: 'failed to delete token' } }) + ) + ), + ], + [ + 'a 404 error without body', + new errors.ResponseError( + securityMock.createApiResponse( + securityMock.createApiResponse({ statusCode: 404, body: {} }) + ) + ), + ], ] as Array<[string, object]>) { it(`throws if call to delete access token responds with ${description}`, async () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - mockClusterClient.callAsInternalUser.mockImplementation((methodName, args: any) => { + mockElasticsearchClient.security.invalidateToken.mockImplementation((args: any) => { if (args && args.body && args.body.token) { - return Promise.reject(failureReason); + return Promise.reject(failureReason) as any; } - return Promise.resolve({ invalidated_tokens: 1 }); + return Promise.resolve( + securityMock.createApiResponse({ body: { invalidated_tokens: 1 } }) + ) as any; }); await expect(tokens.invalidate(tokenPair)).rejects.toBe(failureReason); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( - 'shield.deleteAccessToken', - { - body: { token: tokenPair.accessToken }, - } - ); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( - 'shield.deleteAccessToken', - { - body: { refresh_token: tokenPair.refreshToken }, - } - ); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledTimes(2); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledWith({ + body: { token: tokenPair.accessToken }, + }); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledWith({ + body: { refresh_token: tokenPair.refreshToken }, + }); }); it(`throws if call to delete refresh token responds with ${description}`, async () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - mockClusterClient.callAsInternalUser.mockImplementation((methodName, args: any) => { + mockElasticsearchClient.security.invalidateToken.mockImplementation((args: any) => { if (args && args.body && args.body.refresh_token) { - return Promise.reject(failureReason); + return Promise.reject(failureReason) as any; } - return Promise.resolve({ invalidated_tokens: 1 }); + return Promise.resolve( + securityMock.createApiResponse({ body: { invalidated_tokens: 1 } }) + ) as any; }); await expect(tokens.invalidate(tokenPair)).rejects.toBe(failureReason); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( - 'shield.deleteAccessToken', - { - body: { token: tokenPair.accessToken }, - } - ); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( - 'shield.deleteAccessToken', - { - body: { refresh_token: tokenPair.refreshToken }, - } - ); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledTimes(2); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledWith({ + body: { token: tokenPair.accessToken }, + }); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledWith({ + body: { refresh_token: tokenPair.refreshToken }, + }); }); } it('invalidates all provided tokens', async () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - mockClusterClient.callAsInternalUser.mockResolvedValue({ invalidated_tokens: 1 }); + mockElasticsearchClient.security.invalidateToken.mockResolvedValue( + securityMock.createApiResponse({ body: { invalidated_tokens: 1 } }) + ); await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( - 'shield.deleteAccessToken', - { body: { token: tokenPair.accessToken } } - ); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( - 'shield.deleteAccessToken', - { body: { refresh_token: tokenPair.refreshToken } } - ); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledTimes(2); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledWith({ + body: { token: tokenPair.accessToken }, + }); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledWith({ + body: { refresh_token: tokenPair.refreshToken }, + }); }); it('invalidates only access token if only access token is provided', async () => { const tokenPair = { accessToken: 'foo' }; - mockClusterClient.callAsInternalUser.mockResolvedValue({ invalidated_tokens: 1 }); + mockElasticsearchClient.security.invalidateToken.mockResolvedValue( + securityMock.createApiResponse({ body: { invalidated_tokens: 1 } }) + ); await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( - 'shield.deleteAccessToken', - { body: { token: tokenPair.accessToken } } - ); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledWith({ + body: { token: tokenPair.accessToken }, + }); }); it('invalidates only refresh token if only refresh token is provided', async () => { const tokenPair = { refreshToken: 'foo' }; - mockClusterClient.callAsInternalUser.mockResolvedValue({ invalidated_tokens: 1 }); + mockElasticsearchClient.security.invalidateToken.mockResolvedValue( + securityMock.createApiResponse({ body: { invalidated_tokens: 1 } }) + ); await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( - 'shield.deleteAccessToken', - { body: { refresh_token: tokenPair.refreshToken } } - ); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledWith({ + body: { refresh_token: tokenPair.refreshToken }, + }); }); for (const [description, response] of [ - ['none of the tokens were invalidated', Promise.resolve({ invalidated_tokens: 0 })], + [ + 'none of the tokens were invalidated', + Promise.resolve(securityMock.createApiResponse({ body: { invalidated_tokens: 0 } })), + ], [ '404 error is returned', - Promise.reject({ statusCode: 404, body: { invalidated_tokens: 0 } }), + Promise.resolve( + securityMock.createApiResponse({ statusCode: 404, body: { invalidated_tokens: 0 } }) + ), ], - ] as Array<[string, Promise]>) { + ] as Array<[string, any]>) { it(`does not fail if ${description}`, async () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - mockClusterClient.callAsInternalUser.mockImplementation(() => response); + mockElasticsearchClient.security.invalidateToken.mockImplementation(() => response); await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( - 'shield.deleteAccessToken', - { - body: { token: tokenPair.accessToken }, - } - ); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( - 'shield.deleteAccessToken', - { - body: { refresh_token: tokenPair.refreshToken }, - } - ); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledTimes(2); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledWith({ + body: { token: tokenPair.accessToken }, + }); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledWith({ + body: { refresh_token: tokenPair.refreshToken }, + }); }); } it('does not fail if more than one token per access or refresh token were invalidated', async () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - mockClusterClient.callAsInternalUser.mockResolvedValue({ invalidated_tokens: 5 }); + mockElasticsearchClient.security.invalidateToken.mockResolvedValue( + securityMock.createApiResponse({ body: { invalidated_tokens: 5 } }) + ); await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( - 'shield.deleteAccessToken', - { body: { token: tokenPair.accessToken } } - ); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( - 'shield.deleteAccessToken', - { body: { refresh_token: tokenPair.refreshToken } } - ); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledTimes(2); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledWith({ + body: { token: tokenPair.accessToken }, + }); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledWith({ + body: { refresh_token: tokenPair.refreshToken }, + }); }); }); }); diff --git a/x-pack/plugins/security/server/authentication/tokens.ts b/x-pack/plugins/security/server/authentication/tokens.ts index a435452ae112f2..7bee3dfe1c5a06 100644 --- a/x-pack/plugins/security/server/authentication/tokens.ts +++ b/x-pack/plugins/security/server/authentication/tokens.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyClusterClient, Logger } from '../../../../../src/core/server'; +import type { ElasticsearchClient, Logger } from '../../../../../src/core/server'; import type { AuthenticationInfo } from '../elasticsearch'; import { getErrorStatusCode } from '../errors'; @@ -42,9 +42,7 @@ export class Tokens { */ private readonly logger: Logger; - constructor( - private readonly options: Readonly<{ client: ILegacyClusterClient; logger: Logger }> - ) { + constructor(private readonly options: Readonly<{ client: ElasticsearchClient; logger: Logger }>) { this.logger = options.logger; } @@ -59,9 +57,13 @@ export class Tokens { access_token: accessToken, refresh_token: refreshToken, authentication: authenticationInfo, - } = await this.options.client.callAsInternalUser('shield.getAccessToken', { - body: { grant_type: 'refresh_token', refresh_token: existingRefreshToken }, - }); + } = ( + await this.options.client.security.getToken<{ + access_token: string; + refresh_token: string; + authentication: AuthenticationInfo; + }>({ body: { grant_type: 'refresh_token', refresh_token: existingRefreshToken } }) + ).body; this.logger.debug('Access token has been successfully refreshed.'); @@ -108,10 +110,10 @@ export class Tokens { let invalidatedTokensCount; try { invalidatedTokensCount = ( - await this.options.client.callAsInternalUser('shield.deleteAccessToken', { + await this.options.client.security.invalidateToken<{ invalidated_tokens: number }>({ body: { refresh_token: refreshToken }, }) - ).invalidated_tokens; + ).body.invalidated_tokens; } catch (err) { this.logger.debug(`Failed to invalidate refresh token: ${err.message}`); @@ -140,10 +142,10 @@ export class Tokens { let invalidatedTokensCount; try { invalidatedTokensCount = ( - await this.options.client.callAsInternalUser('shield.deleteAccessToken', { + await this.options.client.security.invalidateToken<{ invalidated_tokens: number }>({ body: { token: accessToken }, }) - ).invalidated_tokens; + ).body.invalidated_tokens; } catch (err) { this.logger.debug(`Failed to invalidate access token: ${err.message}`); diff --git a/x-pack/plugins/security/server/elasticsearch/elasticsearch_client_plugin.ts b/x-pack/plugins/security/server/elasticsearch/elasticsearch_client_plugin.ts deleted file mode 100644 index 0aaad251ae6429..00000000000000 --- a/x-pack/plugins/security/server/elasticsearch/elasticsearch_client_plugin.ts +++ /dev/null @@ -1,214 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export function elasticsearchClientPlugin(Client: any, config: unknown, components: any) { - const ca = components.clientAction.factory; - - Client.prototype.shield = components.clientAction.namespaceFactory(); - const shield = Client.prototype.shield.prototype; - - /** - * Perform a [shield.authenticate](Retrieve details about the currently authenticated user) request - * - * @param {Object} params - An object with parameters used to carry out this action - */ - shield.authenticate = ca({ - params: {}, - url: { - fmt: '/_security/_authenticate', - }, - }); - - /** - * Asks Elasticsearch to prepare SAML authentication request to be sent to - * the 3rd-party SAML identity provider. - * - * @param {string} [acs] Optional assertion consumer service URL to use for SAML request or URL - * in the Kibana to which identity provider will post SAML response. Based on the ACS Elasticsearch - * will choose the right SAML realm. - * - * @param {string} [realm] Optional name of the Elasticsearch SAML realm to use to handle request. - * - * @returns {{realm: string, id: string, redirect: string}} Object that includes identifier - * of the SAML realm used to prepare authentication request, encrypted request token to be - * sent to Elasticsearch with SAML response and redirect URL to the identity provider that - * will be used to authenticate user. - */ - shield.samlPrepare = ca({ - method: 'POST', - needBody: true, - url: { - fmt: '/_security/saml/prepare', - }, - }); - - /** - * Sends SAML response returned by identity provider to Elasticsearch for validation. - * - * @param {Array.} ids A list of encrypted request tokens returned within SAML - * preparation response. - * @param {string} content SAML response returned by identity provider. - * @param {string} [realm] Optional string used to identify the name of the OpenID Connect realm - * that should be used to authenticate request. - * - * @returns {{username: string, access_token: string, expires_in: number}} Object that - * includes name of the user, access token to use for any consequent requests that - * need to be authenticated and a number of seconds after which access token will expire. - */ - shield.samlAuthenticate = ca({ - method: 'POST', - needBody: true, - url: { - fmt: '/_security/saml/authenticate', - }, - }); - - /** - * Invalidates SAML access token. - * - * @param {string} token SAML access token that needs to be invalidated. - * - * @returns {{redirect?: string}} - */ - shield.samlLogout = ca({ - method: 'POST', - needBody: true, - url: { - fmt: '/_security/saml/logout', - }, - }); - - /** - * Invalidates SAML session based on Logout Request received from the Identity Provider. - * - * @param {string} queryString URL encoded query string provided by Identity Provider. - * @param {string} [acs] Optional assertion consumer service URL to use for SAML request or URL in the - * Kibana to which identity provider will post SAML response. Based on the ACS Elasticsearch - * will choose the right SAML realm to invalidate session. - * @param {string} [realm] Optional name of the Elasticsearch SAML realm to use to handle request. - * - * @returns {{redirect?: string}} - */ - shield.samlInvalidate = ca({ - method: 'POST', - needBody: true, - url: { - fmt: '/_security/saml/invalidate', - }, - }); - - /** - * Asks Elasticsearch to prepare an OpenID Connect authentication request to be sent to - * the 3rd-party OpenID Connect provider. - * - * @param {string} realm The OpenID Connect realm name in Elasticsearch - * - * @returns {{state: string, nonce: string, redirect: string}} Object that includes two opaque parameters that need - * to be sent to Elasticsearch with the OpenID Connect response and redirect URL to the OpenID Connect provider that - * will be used to authenticate user. - */ - shield.oidcPrepare = ca({ - method: 'POST', - needBody: true, - url: { - fmt: '/_security/oidc/prepare', - }, - }); - - /** - * Sends the URL to which the OpenID Connect Provider redirected the UA to Elasticsearch for validation. - * - * @param {string} state The state parameter that was returned by Elasticsearch in the - * preparation response. - * @param {string} nonce The nonce parameter that was returned by Elasticsearch in the - * preparation response. - * @param {string} redirect_uri The URL to where the UA was redirected by the OpenID Connect provider. - * @param {string} [realm] Optional string used to identify the name of the OpenID Connect realm - * that should be used to authenticate request. - * - * @returns {{username: string, access_token: string, refresh_token; string, expires_in: number}} Object that - * includes name of the user, access token to use for any consequent requests that - * need to be authenticated and a number of seconds after which access token will expire. - */ - shield.oidcAuthenticate = ca({ - method: 'POST', - needBody: true, - url: { - fmt: '/_security/oidc/authenticate', - }, - }); - - /** - * Invalidates an access token and refresh token pair that was generated after an OpenID Connect authentication. - * - * @param {string} token An access token that was created by authenticating to an OpenID Connect realm and - * that needs to be invalidated. - * @param {string} refresh_token A refresh token that was created by authenticating to an OpenID Connect realm and - * that needs to be invalidated. - * - * @returns {{redirect?: string}} If the Elasticsearch OpenID Connect realm configuration and the - * OpenID Connect provider supports RP-initiated SLO, a URL to redirect the UA - */ - shield.oidcLogout = ca({ - method: 'POST', - needBody: true, - url: { - fmt: '/_security/oidc/logout', - }, - }); - - /** - * Refreshes an access token. - * - * @param {string} grant_type Currently only "refresh_token" grant type is supported. - * @param {string} refresh_token One-time refresh token that will be exchanged to the new access/refresh token pair. - * - * @returns {{access_token: string, type: string, expires_in: number, refresh_token: string}} - */ - shield.getAccessToken = ca({ - method: 'POST', - needBody: true, - url: { - fmt: '/_security/oauth2/token', - }, - }); - - /** - * Invalidates an access token. - * - * @param {string} token The access token to invalidate - * - * @returns {{created: boolean}} - */ - shield.deleteAccessToken = ca({ - method: 'DELETE', - needBody: true, - params: { - token: { - type: 'string', - }, - }, - url: { - fmt: '/_security/oauth2/token', - }, - }); - - /** - * Gets an access token in exchange to the certificate chain for the target subject distinguished name. - * - * @param {string[]} x509_certificate_chain An ordered array of base64-encoded (Section 4 of RFC4648 - not - * base64url-encoded) DER PKIX certificate values. - * - * @returns {{access_token: string, type: string, expires_in: number}} - */ - shield.delegatePKI = ca({ - method: 'POST', - needBody: true, - url: { - fmt: '/_security/delegate_pki', - }, - }); -} diff --git a/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.test.ts b/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.test.ts index 812e3e3d17f991..e58cc4b2caa523 100644 --- a/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.test.ts +++ b/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.test.ts @@ -5,20 +5,11 @@ */ import { BehaviorSubject } from 'rxjs'; -import { - ILegacyCustomClusterClient, - ServiceStatusLevels, - CoreStatus, -} from '../../../../../src/core/server'; +import { ServiceStatusLevels, CoreStatus } from '../../../../../src/core/server'; import { SecurityLicense, SecurityLicenseFeatures } from '../../common/licensing'; -import { elasticsearchClientPlugin } from './elasticsearch_client_plugin'; import { ElasticsearchService } from './elasticsearch_service'; -import { - coreMock, - elasticsearchServiceMock, - loggingSystemMock, -} from '../../../../../src/core/server/mocks'; +import { coreMock, loggingSystemMock } from '../../../../../src/core/server/mocks'; import { licenseMock } from '../../common/licensing/index.mock'; import { nextTick } from '@kbn/test/jest'; @@ -30,35 +21,20 @@ describe('ElasticsearchService', () => { describe('setup()', () => { it('exposes proper contract', () => { - const mockCoreSetup = coreMock.createSetup(); - const mockClusterClient = elasticsearchServiceMock.createLegacyCustomClusterClient(); - mockCoreSetup.elasticsearch.legacy.createClient.mockReturnValue(mockClusterClient); - expect( service.setup({ - elasticsearch: mockCoreSetup.elasticsearch, - status: mockCoreSetup.status, + status: coreMock.createSetup().status, license: licenseMock.create(), }) - ).toEqual({ clusterClient: mockClusterClient }); - - expect(mockCoreSetup.elasticsearch.legacy.createClient).toHaveBeenCalledTimes(1); - expect(mockCoreSetup.elasticsearch.legacy.createClient).toHaveBeenCalledWith('security', { - plugins: [elasticsearchClientPlugin], - }); + ).toBeUndefined(); }); }); describe('start()', () => { - let mockClusterClient: ILegacyCustomClusterClient; let mockLicense: jest.Mocked; let mockStatusSubject: BehaviorSubject; let mockLicenseSubject: BehaviorSubject; beforeEach(() => { - const mockCoreSetup = coreMock.createSetup(); - mockClusterClient = elasticsearchServiceMock.createLegacyCustomClusterClient(); - mockCoreSetup.elasticsearch.legacy.createClient.mockReturnValue(mockClusterClient); - mockLicenseSubject = new BehaviorSubject(({} as unknown) as SecurityLicenseFeatures); mockLicense = licenseMock.create(); mockLicense.isEnabled.mockReturnValue(false); @@ -71,20 +47,18 @@ describe('ElasticsearchService', () => { }, savedObjects: { level: ServiceStatusLevels.unavailable, summary: 'Service is NOT working' }, }); - mockCoreSetup.status.core$ = mockStatusSubject; + + const mockStatus = coreMock.createSetup().status; + mockStatus.core$ = mockStatusSubject; service.setup({ - elasticsearch: mockCoreSetup.elasticsearch, - status: mockCoreSetup.status, + status: mockStatus, license: mockLicense, }); }); it('exposes proper contract', () => { - expect(service.start()).toEqual({ - clusterClient: mockClusterClient, - watchOnlineStatus$: expect.any(Function), - }); + expect(service.start()).toEqual({ watchOnlineStatus$: expect.any(Function) }); }); it('`watchOnlineStatus$` allows tracking of Elasticsearch status', () => { @@ -199,24 +173,4 @@ describe('ElasticsearchService', () => { expect(mockHandler).toHaveBeenCalledTimes(2); }); }); - - describe('stop()', () => { - it('properly closes cluster client instance', () => { - const mockCoreSetup = coreMock.createSetup(); - const mockClusterClient = elasticsearchServiceMock.createLegacyCustomClusterClient(); - mockCoreSetup.elasticsearch.legacy.createClient.mockReturnValue(mockClusterClient); - - service.setup({ - elasticsearch: mockCoreSetup.elasticsearch, - status: mockCoreSetup.status, - license: licenseMock.create(), - }); - - expect(mockClusterClient.close).not.toHaveBeenCalled(); - - service.stop(); - - expect(mockClusterClient.close).toHaveBeenCalledTimes(1); - }); - }); }); diff --git a/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.ts b/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.ts index 42a83b2e5b5272..ace1dc553890da 100644 --- a/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.ts +++ b/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.ts @@ -6,29 +6,15 @@ import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; import { distinctUntilChanged, filter, map, shareReplay, tap } from 'rxjs/operators'; -import { - ILegacyClusterClient, - ILegacyCustomClusterClient, - Logger, - ServiceStatusLevels, - StatusServiceSetup, - ElasticsearchServiceSetup as CoreElasticsearchServiceSetup, -} from '../../../../../src/core/server'; +import { Logger, ServiceStatusLevels, StatusServiceSetup } from '../../../../../src/core/server'; import { SecurityLicense } from '../../common/licensing'; -import { elasticsearchClientPlugin } from './elasticsearch_client_plugin'; export interface ElasticsearchServiceSetupParams { - readonly elasticsearch: CoreElasticsearchServiceSetup; readonly status: StatusServiceSetup; readonly license: SecurityLicense; } -export interface ElasticsearchServiceSetup { - readonly clusterClient: ILegacyClusterClient; -} - export interface ElasticsearchServiceStart { - readonly clusterClient: ILegacyClusterClient; readonly watchOnlineStatus$: () => Observable; } @@ -41,22 +27,13 @@ export interface OnlineStatusRetryScheduler { */ export class ElasticsearchService { readonly #logger: Logger; - #clusterClient?: ILegacyCustomClusterClient; #coreStatus$!: Observable; constructor(logger: Logger) { this.#logger = logger; } - setup({ - elasticsearch, - status, - license, - }: ElasticsearchServiceSetupParams): ElasticsearchServiceSetup { - this.#clusterClient = elasticsearch.legacy.createClient('security', { - plugins: [elasticsearchClientPlugin], - }); - + setup({ status, license }: ElasticsearchServiceSetupParams) { this.#coreStatus$ = combineLatest([status.core$, license.features$]).pipe( map( ([coreStatus]) => @@ -64,14 +41,10 @@ export class ElasticsearchService { ), shareReplay(1) ); - - return { clusterClient: this.#clusterClient }; } start(): ElasticsearchServiceStart { return { - clusterClient: this.#clusterClient!, - // We'll need to get rid of this as soon as Core's Elasticsearch service exposes this // functionality in the scope of https://github.com/elastic/kibana/issues/41983. watchOnlineStatus$: () => { @@ -120,11 +93,4 @@ export class ElasticsearchService { }, }; } - - stop() { - if (this.#clusterClient) { - this.#clusterClient.close(); - this.#clusterClient = undefined; - } - } } diff --git a/x-pack/plugins/security/server/elasticsearch/index.ts b/x-pack/plugins/security/server/elasticsearch/index.ts index 23e4876904c317..c770600db44cdc 100644 --- a/x-pack/plugins/security/server/elasticsearch/index.ts +++ b/x-pack/plugins/security/server/elasticsearch/index.ts @@ -4,12 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AuthenticatedUser } from '../../common/model'; +import type { AuthenticatedUser } from '../../common/model'; export type AuthenticationInfo = Omit; export { ElasticsearchService, - ElasticsearchServiceSetup, ElasticsearchServiceStart, OnlineStatusRetryScheduler, } from './elasticsearch_service'; diff --git a/x-pack/plugins/security/server/mocks.ts b/x-pack/plugins/security/server/mocks.ts index df30d1bf9d6f6d..7d8f3cf36a4adc 100644 --- a/x-pack/plugins/security/server/mocks.ts +++ b/x-pack/plugins/security/server/mocks.ts @@ -14,7 +14,7 @@ function createSetupMock() { const mockAuthz = authorizationMock.create(); return { audit: auditServiceMock.create(), - authc: authenticationServiceMock.createSetup(), + authc: { getCurrentUser: jest.fn() }, authz: { actions: mockAuthz.actions, checkPrivilegesWithRequest: mockAuthz.checkPrivilegesWithRequest, diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index 54efdbdccbb772..256eca376fa026 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -6,11 +6,10 @@ import { of } from 'rxjs'; import { ByteSizeValue } from '@kbn/config-schema'; -import { ILegacyCustomClusterClient } from '../../../../src/core/server'; import { ConfigSchema } from './config'; import { Plugin, PluginSetupDependencies, PluginStartDependencies } from './plugin'; -import { coreMock, elasticsearchServiceMock } from '../../../../src/core/server/mocks'; +import { coreMock } from '../../../../src/core/server/mocks'; import { featuresPluginMock } from '../../features/server/mocks'; import { taskManagerMock } from '../../task_manager/server/mocks'; import { licensingMock } from '../../licensing/server/mocks'; @@ -19,7 +18,6 @@ describe('Security Plugin', () => { let plugin: Plugin; let mockCoreSetup: ReturnType; let mockCoreStart: ReturnType; - let mockClusterClient: jest.Mocked; let mockSetupDependencies: PluginSetupDependencies; let mockStartDependencies: PluginStartDependencies; beforeEach(() => { @@ -43,9 +41,6 @@ describe('Security Plugin', () => { protocol: 'https', }); - mockClusterClient = elasticsearchServiceMock.createLegacyCustomClusterClient(); - mockCoreSetup.elasticsearch.legacy.createClient.mockReturnValue(mockClusterClient); - mockSetupDependencies = ({ licensing: { license$: of({}), featureUsage: { register: jest.fn() } }, features: featuresPluginMock.createSetup(), diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 1016221cb719d1..8d8e4c096f37e5 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -12,6 +12,7 @@ import { SecurityOssPluginSetup } from 'src/plugins/security_oss/server'; import { CoreSetup, CoreStart, + KibanaRequest, Logger, PluginInitializerContext, } from '../../../../src/core/server'; @@ -24,22 +25,19 @@ import { import { LicensingPluginSetup, LicensingPluginStart } from '../../licensing/server'; import { TaskManagerSetupContract, TaskManagerStartContract } from '../../task_manager/server'; -import { - AuthenticationService, - AuthenticationServiceSetup, - AuthenticationServiceStart, -} from './authentication'; +import { AuthenticationService, AuthenticationServiceStart } from './authentication'; import { AuthorizationService, AuthorizationServiceSetup } from './authorization'; import { AnonymousAccessService, AnonymousAccessServiceStart } from './anonymous_access'; import { ConfigSchema, ConfigType, createConfig } from './config'; import { defineRoutes } from './routes'; import { SecurityLicenseService, SecurityLicense } from '../common/licensing'; +import { AuthenticatedUser } from '../common/model'; import { setupSavedObjects } from './saved_objects'; import { AuditService, SecurityAuditLogger, AuditServiceSetup } from './audit'; import { SecurityFeatureUsageService, SecurityFeatureUsageServiceStart } from './feature_usage'; import { securityFeatures } from './features'; import { ElasticsearchService } from './elasticsearch'; -import { SessionManagementService } from './session_management'; +import { Session, SessionManagementService } from './session_management'; import { registerSecurityUsageCollector } from './usage_collector'; import { setupSpacesClient } from './spaces'; @@ -60,7 +58,7 @@ export interface SecurityPluginSetup { /** * @deprecated Use `authc` methods from the `SecurityServiceStart` contract instead. */ - authc: Pick; + authc: { getCurrentUser: (request: KibanaRequest) => AuthenticatedUser | null }; /** * @deprecated Use `authz` methods from the `SecurityServiceStart` contract instead. */ @@ -104,8 +102,8 @@ export interface PluginStartDependencies { */ export class Plugin { private readonly logger: Logger; - private authenticationStart?: AuthenticationServiceStart; private authorizationSetup?: AuthorizationServiceSetup; + private auditSetup?: AuditServiceSetup; private anonymousAccessStart?: AnonymousAccessServiceStart; private configSubscription?: Subscription; @@ -117,6 +115,14 @@ export class Plugin { return this.config; }; + private session?: Session; + private readonly getSession = () => { + if (!this.session) { + throw new Error('Session is not available.'); + } + return this.session; + }; + private kibanaIndexName?: string; private readonly getKibanaIndexName = () => { if (!this.kibanaIndexName) { @@ -125,6 +131,17 @@ export class Plugin { return this.kibanaIndexName; }; + private readonly authenticationService = new AuthenticationService( + this.initializerContext.logger.get('authentication') + ); + private authenticationStart?: AuthenticationServiceStart; + private readonly getAuthentication = () => { + if (!this.authenticationStart) { + throw new Error(`authenticationStart is not registered!`); + } + return this.authenticationStart; + }; + private readonly featureUsageService = new SecurityFeatureUsageService(); private featureUsageServiceStart?: SecurityFeatureUsageServiceStart; private readonly getFeatureUsageService = () => { @@ -143,9 +160,6 @@ export class Plugin { private readonly sessionManagementService = new SessionManagementService( this.initializerContext.logger.get('session') ); - private readonly authenticationService = new AuthenticationService( - this.initializerContext.logger.get('authentication') - ); private readonly anonymousAccessService = new AnonymousAccessService( this.initializerContext.logger.get('anonymous-access'), this.getConfig @@ -211,46 +225,22 @@ export class Plugin { features.registerElasticsearchFeature(securityFeature) ); - const { clusterClient } = this.elasticsearchService.setup({ - elasticsearch: core.elasticsearch, - license, - status: core.status, - }); - + this.elasticsearchService.setup({ license, status: core.status }); this.featureUsageService.setup({ featureUsage: licensing.featureUsage }); + this.sessionManagementService.setup({ config, http: core.http, taskManager }); + this.authenticationService.setup({ http: core.http, license }); registerSecurityUsageCollector({ usageCollection, config, license }); - const { session } = this.sessionManagementService.setup({ - config, - clusterClient, - http: core.http, - kibanaIndexName, - taskManager, - }); - - const audit = this.auditService.setup({ + this.auditSetup = this.auditService.setup({ license, config: config.audit, logging: core.logging, http: core.http, getSpaceId: (request) => spaces?.spacesService.getSpaceId(request), - getSID: (request) => session.getSID(request), - getCurrentUser: (request) => authenticationSetup.getCurrentUser(request), - recordAuditLoggingUsage: () => this.featureUsageServiceStart?.recordAuditLoggingUsage(), - }); - const legacyAuditLogger = new SecurityAuditLogger(audit.getLogger()); - - const authenticationSetup = this.authenticationService.setup({ - legacyAuditLogger, - audit, - getFeatureUsageService: this.getFeatureUsageService, - http: core.http, - clusterClient, - config, - license, - loggers: this.initializerContext.logger, - session, + getSID: (request) => this.getSession().getSID(request), + getCurrentUser: (request) => this.getAuthentication().getCurrentUser(request), + recordAuditLoggingUsage: () => this.getFeatureUsageService().recordAuditLoggingUsage(), }); this.anonymousAccessService.setup(); @@ -267,18 +257,18 @@ export class Plugin { buildNumber: this.initializerContext.env.packageInfo.buildNum, getSpacesService: () => spaces?.spacesService, features, - getCurrentUser: authenticationSetup.getCurrentUser, + getCurrentUser: (request) => this.getAuthentication().getCurrentUser(request), }); setupSpacesClient({ spaces, - audit, + audit: this.auditSetup, authz: this.authorizationSetup, }); setupSavedObjects({ - legacyAuditLogger, - audit, + legacyAuditLogger: new SecurityAuditLogger(this.auditSetup.getLogger()), + audit: this.auditSetup, authz: this.authorizationSetup, savedObjects: core.savedObjects, getSpacesService: () => spaces?.spacesService, @@ -292,26 +282,20 @@ export class Plugin { config, authz: this.authorizationSetup, license, - session, + getSession: this.getSession, getFeatures: () => startServicesPromise.then((services) => services.features.getKibanaFeatures()), getFeatureUsageService: this.getFeatureUsageService, - getAuthenticationService: () => { - if (!this.authenticationStart) { - throw new Error('Authentication service is not started!'); - } - - return this.authenticationStart; - }, + getAuthenticationService: this.getAuthentication, }); return Object.freeze({ audit: { - asScoped: audit.asScoped, - getLogger: audit.getLogger, + asScoped: this.auditSetup.asScoped, + getLogger: this.auditSetup.getLogger, }, - authc: { getCurrentUser: authenticationSetup.getCurrentUser }, + authc: { getCurrentUser: (request) => this.getAuthentication().getCurrentUser(request) }, authz: { actions: this.authorizationSetup.actions, @@ -337,11 +321,24 @@ export class Plugin { const clusterClient = core.elasticsearch.client; const { watchOnlineStatus$ } = this.elasticsearchService.start(); + const { session } = this.sessionManagementService.start({ + elasticsearchClient: clusterClient.asInternalUser, + kibanaIndexName: this.getKibanaIndexName(), + online$: watchOnlineStatus$(), + taskManager, + }); + this.session = session; - this.sessionManagementService.start({ online$: watchOnlineStatus$(), taskManager }); + const config = this.getConfig(); this.authenticationStart = this.authenticationService.start({ - http: core.http, + audit: this.auditSetup!, clusterClient, + config, + featureUsageService: this.featureUsageServiceStart, + http: core.http, + legacyAuditLogger: new SecurityAuditLogger(this.auditSetup!.getLogger()), + loggers: this.initializerContext.logger, + session, }); this.authorizationService.start({ features, clusterClient, online$: watchOnlineStatus$() }); @@ -391,7 +388,6 @@ export class Plugin { this.securityLicenseService.stop(); this.auditService.stop(); this.authorizationService.stop(); - this.elasticsearchService.stop(); this.sessionManagementService.stop(); } } diff --git a/x-pack/plugins/security/server/routes/index.mock.ts b/x-pack/plugins/security/server/routes/index.mock.ts index 4103594faba157..f7b51eeffe6ede 100644 --- a/x-pack/plugins/security/server/routes/index.mock.ts +++ b/x-pack/plugins/security/server/routes/index.mock.ts @@ -32,7 +32,7 @@ export const routeDefinitionParamsMock = { httpResources: httpResourcesMock.createRegistrar(), getFeatures: jest.fn(), getFeatureUsageService: jest.fn(), - session: sessionMock.create(), + getSession: jest.fn().mockReturnValue(sessionMock.create()), getAuthenticationService: jest.fn().mockReturnValue(authenticationServiceMock.createStart()), } as unknown) as DeeplyMockedKeys), }; diff --git a/x-pack/plugins/security/server/routes/index.ts b/x-pack/plugins/security/server/routes/index.ts index 2d49329fd63d32..9a8fe2e0d6d15d 100644 --- a/x-pack/plugins/security/server/routes/index.ts +++ b/x-pack/plugins/security/server/routes/index.ts @@ -33,7 +33,7 @@ export interface RouteDefinitionParams { logger: Logger; config: ConfigType; authz: AuthorizationServiceSetup; - session: PublicMethodsOf; + getSession: () => PublicMethodsOf; license: SecurityLicense; getFeatures: () => Promise; getFeatureUsageService: () => SecurityFeatureUsageServiceStart; diff --git a/x-pack/plugins/security/server/routes/session_management/info.test.ts b/x-pack/plugins/security/server/routes/session_management/info.test.ts index c51956f3fe5303..b068e80cfa8598 100644 --- a/x-pack/plugins/security/server/routes/session_management/info.test.ts +++ b/x-pack/plugins/security/server/routes/session_management/info.test.ts @@ -24,7 +24,9 @@ describe('Info session routes', () => { beforeEach(() => { const routeParamsMock = routeDefinitionParamsMock.create(); router = routeParamsMock.router; - session = routeParamsMock.session; + + session = sessionMock.create(); + routeParamsMock.getSession.mockReturnValue(session); defineSessionInfoRoutes(routeParamsMock); }); diff --git a/x-pack/plugins/security/server/routes/session_management/info.ts b/x-pack/plugins/security/server/routes/session_management/info.ts index 381127284f7803..1f73edf510976f 100644 --- a/x-pack/plugins/security/server/routes/session_management/info.ts +++ b/x-pack/plugins/security/server/routes/session_management/info.ts @@ -10,12 +10,12 @@ import { RouteDefinitionParams } from '..'; /** * Defines routes required for the session info. */ -export function defineSessionInfoRoutes({ router, logger, session }: RouteDefinitionParams) { +export function defineSessionInfoRoutes({ router, logger, getSession }: RouteDefinitionParams) { router.get( { path: '/internal/security/session', validate: false }, async (_context, request, response) => { try { - const sessionValue = await session.get(request); + const sessionValue = await getSession().get(request); if (sessionValue) { return response.ok({ body: { diff --git a/x-pack/plugins/security/server/routes/users/change_password.test.ts b/x-pack/plugins/security/server/routes/users/change_password.test.ts index 24e73e456619b3..2c7c3f6bafc405 100644 --- a/x-pack/plugins/security/server/routes/users/change_password.test.ts +++ b/x-pack/plugins/security/server/routes/users/change_password.test.ts @@ -48,7 +48,10 @@ describe('Change password', () => { beforeEach(() => { const routeParamsMock = routeDefinitionParamsMock.create(); router = routeParamsMock.router; - session = routeParamsMock.session; + + session = sessionMock.create(); + routeParamsMock.getSession.mockReturnValue(session); + authc = authenticationServiceMock.createStart(); routeParamsMock.getAuthenticationService.mockReturnValue(authc); diff --git a/x-pack/plugins/security/server/routes/users/change_password.ts b/x-pack/plugins/security/server/routes/users/change_password.ts index 7b53afceb48fdf..1c9086862c37d2 100644 --- a/x-pack/plugins/security/server/routes/users/change_password.ts +++ b/x-pack/plugins/security/server/routes/users/change_password.ts @@ -16,7 +16,7 @@ import { RouteDefinitionParams } from '..'; export function defineChangeUserPasswordRoutes({ getAuthenticationService, - session, + getSession, router, }: RouteDefinitionParams) { router.post( @@ -37,7 +37,7 @@ export function defineChangeUserPasswordRoutes({ const currentUser = getAuthenticationService().getCurrentUser(request); const isUserChangingOwnPassword = currentUser && currentUser.username === username && canUserChangePassword(currentUser); - const currentSession = isUserChangingOwnPassword ? await session.get(request) : null; + const currentSession = isUserChangingOwnPassword ? await getSession().get(request) : null; // If user is changing their own password they should provide a proof of knowledge their // current password via sending it in `Authorization: Basic base64(username:current password)` diff --git a/x-pack/plugins/security/server/routes/views/access_agreement.test.ts b/x-pack/plugins/security/server/routes/views/access_agreement.test.ts index 4c4f8a22eee235..a471f5f4e84cb1 100644 --- a/x-pack/plugins/security/server/routes/views/access_agreement.test.ts +++ b/x-pack/plugins/security/server/routes/views/access_agreement.test.ts @@ -33,10 +33,12 @@ describe('Access agreement view routes', () => { const routeParamsMock = routeDefinitionParamsMock.create(); router = routeParamsMock.router; httpResources = routeParamsMock.httpResources; - session = routeParamsMock.session; config = routeParamsMock.config; license = routeParamsMock.license; + session = sessionMock.create(); + routeParamsMock.getSession.mockReturnValue(session); + license.getFeatures.mockReturnValue({ allowAccessAgreement: true, } as SecurityLicenseFeatures); diff --git a/x-pack/plugins/security/server/routes/views/access_agreement.ts b/x-pack/plugins/security/server/routes/views/access_agreement.ts index 80a1c2a20cf599..c7f694eca68cea 100644 --- a/x-pack/plugins/security/server/routes/views/access_agreement.ts +++ b/x-pack/plugins/security/server/routes/views/access_agreement.ts @@ -12,7 +12,7 @@ import { RouteDefinitionParams } from '..'; * Defines routes required for the Access Agreement view. */ export function defineAccessAgreementRoutes({ - session, + getSession, httpResources, license, config, @@ -46,7 +46,7 @@ export function defineAccessAgreementRoutes({ // authenticated with the help of HTTP authentication), that means we should safely check if // we have it and can get a corresponding configuration. try { - const sessionValue = await session.get(request); + const sessionValue = await getSession().get(request); const accessAgreement = (sessionValue && config.authc.providers[ diff --git a/x-pack/plugins/security/server/routes/views/logged_out.test.ts b/x-pack/plugins/security/server/routes/views/logged_out.test.ts index 31096bc33d686f..7cc534663e2f92 100644 --- a/x-pack/plugins/security/server/routes/views/logged_out.test.ts +++ b/x-pack/plugins/security/server/routes/views/logged_out.test.ts @@ -18,7 +18,8 @@ describe('LoggedOut view routes', () => { let routeConfig: RouteConfig; beforeEach(() => { const routeParamsMock = routeDefinitionParamsMock.create(); - session = routeParamsMock.session; + session = sessionMock.create(); + routeParamsMock.getSession.mockReturnValue(session); defineLoggedOutRoutes(routeParamsMock); diff --git a/x-pack/plugins/security/server/routes/views/logged_out.ts b/x-pack/plugins/security/server/routes/views/logged_out.ts index b35154e6a0f2a4..97357118907d3b 100644 --- a/x-pack/plugins/security/server/routes/views/logged_out.ts +++ b/x-pack/plugins/security/server/routes/views/logged_out.ts @@ -17,7 +17,7 @@ import { RouteDefinitionParams } from '..'; */ export function defineLoggedOutRoutes({ logger, - session, + getSession, httpResources, basePath, }: RouteDefinitionParams) { @@ -30,7 +30,7 @@ export function defineLoggedOutRoutes({ async (context, request, response) => { // Authentication flow isn't triggered automatically for this route, so we should explicitly // check whether user has an active session already. - const isUserAlreadyLoggedIn = (await session.get(request)) !== null; + const isUserAlreadyLoggedIn = (await getSession().get(request)) !== null; if (isUserAlreadyLoggedIn) { logger.debug('User is already authenticated, redirecting...'); return response.redirected({ diff --git a/x-pack/plugins/security/server/session_management/index.ts b/x-pack/plugins/security/server/session_management/index.ts index ee7ed914947a04..1d256885f49f23 100644 --- a/x-pack/plugins/security/server/session_management/index.ts +++ b/x-pack/plugins/security/server/session_management/index.ts @@ -6,6 +6,6 @@ export { Session, SessionValue } from './session'; export { - SessionManagementServiceSetup, + SessionManagementServiceStart, SessionManagementService, } from './session_management_service'; diff --git a/x-pack/plugins/security/server/session_management/session_index.test.ts b/x-pack/plugins/security/server/session_management/session_index.test.ts index 1dd47c7ff66e84..51abcfe00253c9 100644 --- a/x-pack/plugins/security/server/session_management/session_index.test.ts +++ b/x-pack/plugins/security/server/session_management/session_index.test.ts @@ -4,27 +4,30 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyClusterClient } from '../../../../../src/core/server'; +import { errors } from '@elastic/elasticsearch'; +import { DeeplyMockedKeys } from '@kbn/utility-types/jest'; +import { ElasticsearchClient } from '../../../../../src/core/server'; import { ConfigSchema, createConfig } from '../config'; import { getSessionIndexTemplate, SessionIndex } from './session_index'; import { loggingSystemMock, elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; +import { securityMock } from '../mocks'; import { sessionIndexMock } from './session_index.mock'; describe('Session index', () => { - let mockClusterClient: jest.Mocked; + let mockElasticsearchClient: DeeplyMockedKeys; let sessionIndex: SessionIndex; const indexName = '.kibana_some_tenant_security_session_1'; const indexTemplateName = '.kibana_some_tenant_security_session_index_template_1'; beforeEach(() => { - mockClusterClient = elasticsearchServiceMock.createLegacyClusterClient(); + mockElasticsearchClient = elasticsearchServiceMock.createElasticsearchClient(); const sessionIndexOptions = { logger: loggingSystemMock.createLogger(), kibanaIndexName: '.kibana_some_tenant', config: createConfig(ConfigSchema.validate({}), loggingSystemMock.createLogger(), { isTLSEnabled: false, }), - clusterClient: mockClusterClient, + elasticsearchClient: mockElasticsearchClient, }; sessionIndex = new SessionIndex(sessionIndexOptions); @@ -32,22 +35,21 @@ describe('Session index', () => { describe('#initialize', () => { function assertExistenceChecksPerformed() { - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.existsTemplate', { + expect(mockElasticsearchClient.indices.existsTemplate).toHaveBeenCalledWith({ name: indexTemplateName, }); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.exists', { + expect(mockElasticsearchClient.indices.exists).toHaveBeenCalledWith({ index: getSessionIndexTemplate(indexName).index_patterns, }); } it('debounces initialize calls', async () => { - mockClusterClient.callAsInternalUser.mockImplementation(async (method) => { - if (method === 'indices.existsTemplate' || method === 'indices.exists') { - return true; - } - - throw new Error('Unexpected call'); - }); + mockElasticsearchClient.indices.existsTemplate.mockResolvedValue( + securityMock.createApiResponse({ body: true }) + ); + mockElasticsearchClient.indices.exists.mockResolvedValue( + securityMock.createApiResponse({ body: true }) + ); await Promise.all([ sessionIndex.initialize(), @@ -56,112 +58,102 @@ describe('Session index', () => { sessionIndex.initialize(), ]); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2); assertExistenceChecksPerformed(); }); it('creates neither index template nor index if they exist', async () => { - mockClusterClient.callAsInternalUser.mockImplementation(async (method) => { - if (method === 'indices.existsTemplate' || method === 'indices.exists') { - return true; - } - - throw new Error('Unexpected call'); - }); + mockElasticsearchClient.indices.existsTemplate.mockResolvedValue( + securityMock.createApiResponse({ body: true }) + ); + mockElasticsearchClient.indices.exists.mockResolvedValue( + securityMock.createApiResponse({ body: true }) + ); await sessionIndex.initialize(); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2); assertExistenceChecksPerformed(); }); it('creates both index template and index if they do not exist', async () => { - mockClusterClient.callAsInternalUser.mockImplementation(async (method) => { - if (method === 'indices.existsTemplate' || method === 'indices.exists') { - return false; - } - }); + mockElasticsearchClient.indices.existsTemplate.mockResolvedValue( + securityMock.createApiResponse({ body: false }) + ); + mockElasticsearchClient.indices.exists.mockResolvedValue( + securityMock.createApiResponse({ body: false }) + ); await sessionIndex.initialize(); const expectedIndexTemplate = getSessionIndexTemplate(indexName); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(4); assertExistenceChecksPerformed(); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.putTemplate', { + expect(mockElasticsearchClient.indices.putTemplate).toHaveBeenCalledWith({ name: indexTemplateName, body: expectedIndexTemplate, }); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.create', { + expect(mockElasticsearchClient.indices.create).toHaveBeenCalledWith({ index: expectedIndexTemplate.index_patterns, }); }); it('creates only index template if it does not exist even if index exists', async () => { - mockClusterClient.callAsInternalUser.mockImplementation(async (method) => { - if (method === 'indices.existsTemplate') { - return false; - } - - if (method === 'indices.exists') { - return true; - } - }); + mockElasticsearchClient.indices.existsTemplate.mockResolvedValue( + securityMock.createApiResponse({ body: false }) + ); + mockElasticsearchClient.indices.exists.mockResolvedValue( + securityMock.createApiResponse({ body: true }) + ); await sessionIndex.initialize(); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(3); assertExistenceChecksPerformed(); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.putTemplate', { + expect(mockElasticsearchClient.indices.putTemplate).toHaveBeenCalledWith({ name: indexTemplateName, body: getSessionIndexTemplate(indexName), }); }); it('creates only index if it does not exist even if index template exists', async () => { - mockClusterClient.callAsInternalUser.mockImplementation(async (method) => { - if (method === 'indices.existsTemplate') { - return true; - } - - if (method === 'indices.exists') { - return false; - } - }); + mockElasticsearchClient.indices.existsTemplate.mockResolvedValue( + securityMock.createApiResponse({ body: true }) + ); + mockElasticsearchClient.indices.exists.mockResolvedValue( + securityMock.createApiResponse({ body: false }) + ); await sessionIndex.initialize(); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(3); assertExistenceChecksPerformed(); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.create', { + expect(mockElasticsearchClient.indices.create).toHaveBeenCalledWith({ index: getSessionIndexTemplate(indexName).index_patterns, }); }); it('does not fail if tries to create index when it exists already', async () => { - mockClusterClient.callAsInternalUser.mockImplementation(async (method) => { - if (method === 'indices.existsTemplate') { - return true; - } - - if (method === 'indices.exists') { - return false; - } - - if (method === 'indices.create') { - // eslint-disable-next-line no-throw-literal - throw { body: { error: { type: 'resource_already_exists_exception' } } }; - } - }); + mockElasticsearchClient.indices.existsTemplate.mockResolvedValue( + securityMock.createApiResponse({ body: true }) + ); + mockElasticsearchClient.indices.exists.mockResolvedValue( + securityMock.createApiResponse({ body: false }) + ); + mockElasticsearchClient.indices.create.mockRejectedValue( + new errors.ResponseError( + securityMock.createApiResponse({ + body: { error: { type: 'resource_already_exists_exception' } }, + }) + ) + ); await sessionIndex.initialize(); }); it('works properly after failure', async () => { - const unexpectedError = new Error('Uh! Oh!'); - mockClusterClient.callAsInternalUser.mockImplementationOnce(() => - Promise.reject(unexpectedError) + const unexpectedError = new errors.ResponseError( + securityMock.createApiResponse(securityMock.createApiResponse({ body: { type: 'Uh oh.' } })) + ); + mockElasticsearchClient.indices.existsTemplate.mockRejectedValueOnce(unexpectedError); + mockElasticsearchClient.indices.existsTemplate.mockResolvedValueOnce( + securityMock.createApiResponse({ body: true }) ); - mockClusterClient.callAsInternalUser.mockImplementationOnce(() => Promise.resolve(true)); await expect(sessionIndex.initialize()).rejects.toBe(unexpectedError); await expect(sessionIndex.initialize()).resolves.toBe(undefined); @@ -171,13 +163,17 @@ describe('Session index', () => { describe('cleanUp', () => { const now = 123456; beforeEach(() => { - mockClusterClient.callAsInternalUser.mockResolvedValue({}); + mockElasticsearchClient.deleteByQuery.mockResolvedValue( + securityMock.createApiResponse({ body: {} }) + ); jest.spyOn(Date, 'now').mockImplementation(() => now); }); it('throws if call to Elasticsearch fails', async () => { - const failureReason = new Error('Uh oh.'); - mockClusterClient.callAsInternalUser.mockRejectedValue(failureReason); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse(securityMock.createApiResponse({ body: { type: 'Uh oh.' } })) + ); + mockElasticsearchClient.deleteByQuery.mockRejectedValue(failureReason); await expect(sessionIndex.cleanUp()).rejects.toBe(failureReason); }); @@ -185,53 +181,55 @@ describe('Session index', () => { it('when neither `lifespan` nor `idleTimeout` is configured', async () => { await sessionIndex.cleanUp(); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('deleteByQuery', { - index: indexName, - refresh: 'wait_for', - ignore: [409, 404], - body: { - query: { - bool: { - should: [ - // All expired sessions based on the lifespan, no matter which provider they belong to. - { range: { lifespanExpiration: { lte: now } } }, - // All sessions that belong to the providers that aren't configured. - { - bool: { - must_not: { - bool: { - should: [ - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic' } }, - ], + expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledWith( + { + index: indexName, + refresh: true, + body: { + query: { + bool: { + should: [ + // All expired sessions based on the lifespan, no matter which provider they belong to. + { range: { lifespanExpiration: { lte: now } } }, + // All sessions that belong to the providers that aren't configured. + { + bool: { + must_not: { + bool: { + should: [ + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic' } }, + ], + }, }, - }, - ], - minimum_should_match: 1, + ], + minimum_should_match: 1, + }, }, }, }, - }, - // The sessions that belong to a particular provider that are expired based on the idle timeout. - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic' } }, - ], - should: [{ range: { idleTimeoutExpiration: { lte: now } } }], - minimum_should_match: 1, + // The sessions that belong to a particular provider that are expired based on the idle timeout. + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic' } }, + ], + should: [{ range: { idleTimeoutExpiration: { lte: now } } }], + minimum_should_match: 1, + }, }, - }, - ], + ], + }, }, }, }, - }); + { ignore: [409, 404] } + ); }); it('when only `lifespan` is configured', async () => { @@ -243,68 +241,70 @@ describe('Session index', () => { loggingSystemMock.createLogger(), { isTLSEnabled: false } ), - clusterClient: mockClusterClient, + elasticsearchClient: mockElasticsearchClient, }); await sessionIndex.cleanUp(); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('deleteByQuery', { - index: indexName, - refresh: 'wait_for', - ignore: [409, 404], - body: { - query: { - bool: { - should: [ - // All expired sessions based on the lifespan, no matter which provider they belong to. - { range: { lifespanExpiration: { lte: now } } }, - // All sessions that belong to the providers that aren't configured. - { - bool: { - must_not: { - bool: { - should: [ - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic' } }, - ], + expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledWith( + { + index: indexName, + refresh: true, + body: { + query: { + bool: { + should: [ + // All expired sessions based on the lifespan, no matter which provider they belong to. + { range: { lifespanExpiration: { lte: now } } }, + // All sessions that belong to the providers that aren't configured. + { + bool: { + must_not: { + bool: { + should: [ + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic' } }, + ], + }, }, - }, - ], - minimum_should_match: 1, + ], + minimum_should_match: 1, + }, }, }, }, - }, - // The sessions that belong to a particular provider but don't have a configured lifespan. - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic' } }, - ], - must_not: { exists: { field: 'lifespanExpiration' } }, + // The sessions that belong to a particular provider but don't have a configured lifespan. + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic' } }, + ], + must_not: { exists: { field: 'lifespanExpiration' } }, + }, }, - }, - // The sessions that belong to a particular provider that are expired based on the idle timeout. - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic' } }, - ], - should: [{ range: { idleTimeoutExpiration: { lte: now } } }], - minimum_should_match: 1, + // The sessions that belong to a particular provider that are expired based on the idle timeout. + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic' } }, + ], + should: [{ range: { idleTimeoutExpiration: { lte: now } } }], + minimum_should_match: 1, + }, }, - }, - ], + ], + }, }, }, }, - }); + { ignore: [409, 404] } + ); }); it('when only `idleTimeout` is configured', async () => { @@ -317,62 +317,64 @@ describe('Session index', () => { loggingSystemMock.createLogger(), { isTLSEnabled: false } ), - clusterClient: mockClusterClient, + elasticsearchClient: mockElasticsearchClient, }); await sessionIndex.cleanUp(); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('deleteByQuery', { - index: indexName, - refresh: 'wait_for', - ignore: [409, 404], - body: { - query: { - bool: { - should: [ - // All expired sessions based on the lifespan, no matter which provider they belong to. - { range: { lifespanExpiration: { lte: now } } }, - // All sessions that belong to the providers that aren't configured. - { - bool: { - must_not: { - bool: { - should: [ - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic' } }, - ], + expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledWith( + { + index: indexName, + refresh: true, + body: { + query: { + bool: { + should: [ + // All expired sessions based on the lifespan, no matter which provider they belong to. + { range: { lifespanExpiration: { lte: now } } }, + // All sessions that belong to the providers that aren't configured. + { + bool: { + must_not: { + bool: { + should: [ + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic' } }, + ], + }, }, - }, - ], - minimum_should_match: 1, + ], + minimum_should_match: 1, + }, }, }, }, - }, - // The sessions that belong to a particular provider that are either expired based on the idle timeout - // or don't have it configured at all. - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic' } }, - ], - should: [ - { range: { idleTimeoutExpiration: { lte: now - 3 * idleTimeout } } }, - { bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } }, - ], - minimum_should_match: 1, + // The sessions that belong to a particular provider that are either expired based on the idle timeout + // or don't have it configured at all. + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic' } }, + ], + should: [ + { range: { idleTimeoutExpiration: { lte: now - 3 * idleTimeout } } }, + { bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } }, + ], + minimum_should_match: 1, + }, }, - }, - ], + ], + }, }, }, }, - }); + { ignore: [409, 404] } + ); }); it('when both `lifespan` and `idleTimeout` are configured', async () => { @@ -385,72 +387,74 @@ describe('Session index', () => { loggingSystemMock.createLogger(), { isTLSEnabled: false } ), - clusterClient: mockClusterClient, + elasticsearchClient: mockElasticsearchClient, }); await sessionIndex.cleanUp(); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('deleteByQuery', { - index: indexName, - refresh: 'wait_for', - ignore: [409, 404], - body: { - query: { - bool: { - should: [ - // All expired sessions based on the lifespan, no matter which provider they belong to. - { range: { lifespanExpiration: { lte: now } } }, - // All sessions that belong to the providers that aren't configured. - { - bool: { - must_not: { - bool: { - should: [ - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic' } }, - ], + expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledWith( + { + index: indexName, + refresh: true, + body: { + query: { + bool: { + should: [ + // All expired sessions based on the lifespan, no matter which provider they belong to. + { range: { lifespanExpiration: { lte: now } } }, + // All sessions that belong to the providers that aren't configured. + { + bool: { + must_not: { + bool: { + should: [ + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic' } }, + ], + }, }, - }, - ], - minimum_should_match: 1, + ], + minimum_should_match: 1, + }, }, }, }, - }, - // The sessions that belong to a particular provider but don't have a configured lifespan. - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic' } }, - ], - must_not: { exists: { field: 'lifespanExpiration' } }, + // The sessions that belong to a particular provider but don't have a configured lifespan. + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic' } }, + ], + must_not: { exists: { field: 'lifespanExpiration' } }, + }, }, - }, - // The sessions that belong to a particular provider that are either expired based on the idle timeout - // or don't have it configured at all. - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic' } }, - ], - should: [ - { range: { idleTimeoutExpiration: { lte: now - 3 * idleTimeout } } }, - { bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } }, - ], - minimum_should_match: 1, + // The sessions that belong to a particular provider that are either expired based on the idle timeout + // or don't have it configured at all. + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic' } }, + ], + should: [ + { range: { idleTimeoutExpiration: { lte: now - 3 * idleTimeout } } }, + { bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } }, + ], + minimum_should_match: 1, + }, }, - }, - ], + ], + }, }, }, }, - }); + { ignore: [409, 404] } + ); }); it('when both `lifespan` and `idleTimeout` are configured and multiple providers are enabled', async () => { @@ -478,127 +482,132 @@ describe('Session index', () => { loggingSystemMock.createLogger(), { isTLSEnabled: false } ), - clusterClient: mockClusterClient, + elasticsearchClient: mockElasticsearchClient, }); await sessionIndex.cleanUp(); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('deleteByQuery', { - index: indexName, - refresh: 'wait_for', - ignore: [409, 404], - body: { - query: { - bool: { - should: [ - // All expired sessions based on the lifespan, no matter which provider they belong to. - { range: { lifespanExpiration: { lte: now } } }, - // All sessions that belong to the providers that aren't configured. - { - bool: { - must_not: { - bool: { - should: [ - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic1' } }, - ], + expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledWith( + { + index: indexName, + refresh: true, + body: { + query: { + bool: { + should: [ + // All expired sessions based on the lifespan, no matter which provider they belong to. + { range: { lifespanExpiration: { lte: now } } }, + // All sessions that belong to the providers that aren't configured. + { + bool: { + must_not: { + bool: { + should: [ + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic1' } }, + ], + }, }, - }, - { - bool: { - must: [ - { term: { 'provider.type': 'saml' } }, - { term: { 'provider.name': 'saml1' } }, - ], + { + bool: { + must: [ + { term: { 'provider.type': 'saml' } }, + { term: { 'provider.name': 'saml1' } }, + ], + }, }, - }, - ], - minimum_should_match: 1, + ], + minimum_should_match: 1, + }, }, }, }, - }, - // The sessions that belong to a Basic provider but don't have a configured lifespan. - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic1' } }, - ], - must_not: { exists: { field: 'lifespanExpiration' } }, + // The sessions that belong to a Basic provider but don't have a configured lifespan. + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic1' } }, + ], + must_not: { exists: { field: 'lifespanExpiration' } }, + }, }, - }, - // The sessions that belong to a Basic provider that are either expired based on the idle timeout - // or don't have it configured at all. - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic1' } }, - ], - should: [ - { range: { idleTimeoutExpiration: { lte: now - 3 * globalIdleTimeout } } }, - { bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } }, - ], - minimum_should_match: 1, + // The sessions that belong to a Basic provider that are either expired based on the idle timeout + // or don't have it configured at all. + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic1' } }, + ], + should: [ + { range: { idleTimeoutExpiration: { lte: now - 3 * globalIdleTimeout } } }, + { bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } }, + ], + minimum_should_match: 1, + }, }, - }, - // The sessions that belong to a SAML provider but don't have a configured lifespan. - { - bool: { - must: [ - { term: { 'provider.type': 'saml' } }, - { term: { 'provider.name': 'saml1' } }, - ], - must_not: { exists: { field: 'lifespanExpiration' } }, + // The sessions that belong to a SAML provider but don't have a configured lifespan. + { + bool: { + must: [ + { term: { 'provider.type': 'saml' } }, + { term: { 'provider.name': 'saml1' } }, + ], + must_not: { exists: { field: 'lifespanExpiration' } }, + }, }, - }, - // The sessions that belong to a SAML provider that are either expired based on the idle timeout - // or don't have it configured at all. - { - bool: { - must: [ - { term: { 'provider.type': 'saml' } }, - { term: { 'provider.name': 'saml1' } }, - ], - should: [ - { range: { idleTimeoutExpiration: { lte: now - 3 * samlIdleTimeout } } }, - { bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } }, - ], - minimum_should_match: 1, + // The sessions that belong to a SAML provider that are either expired based on the idle timeout + // or don't have it configured at all. + { + bool: { + must: [ + { term: { 'provider.type': 'saml' } }, + { term: { 'provider.name': 'saml1' } }, + ], + should: [ + { range: { idleTimeoutExpiration: { lte: now - 3 * samlIdleTimeout } } }, + { bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } }, + ], + minimum_should_match: 1, + }, }, - }, - ], + ], + }, }, }, }, - }); + { ignore: [409, 404] } + ); }); }); describe('#get', () => { it('throws if call to Elasticsearch fails', async () => { - const failureReason = new Error('Uh oh.'); - mockClusterClient.callAsInternalUser.mockRejectedValue(failureReason); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse(securityMock.createApiResponse({ body: { type: 'Uh oh.' } })) + ); + mockElasticsearchClient.get.mockRejectedValue(failureReason); await expect(sessionIndex.get('some-sid')).rejects.toBe(failureReason); }); it('returns `null` if index is not found', async () => { - mockClusterClient.callAsInternalUser.mockResolvedValue({ status: 404 }); + mockElasticsearchClient.get.mockResolvedValue( + securityMock.createApiResponse({ statusCode: 404, body: { status: 404 } }) + ); await expect(sessionIndex.get('some-sid')).resolves.toBeNull(); }); it('returns `null` if session index value document is not found', async () => { - mockClusterClient.callAsInternalUser.mockResolvedValue({ - found: false, - status: 200, - }); + mockElasticsearchClient.get.mockResolvedValue( + securityMock.createApiResponse({ body: { status: 200, found: false } }) + ); await expect(sessionIndex.get('some-sid')).resolves.toBeNull(); }); @@ -612,13 +621,17 @@ describe('Session index', () => { content: 'some-encrypted-content', }; - mockClusterClient.callAsInternalUser.mockResolvedValue({ - found: true, - status: 200, - _source: indexDocumentSource, - _primary_term: 1, - _seq_no: 456, - }); + mockElasticsearchClient.get.mockResolvedValue( + securityMock.createApiResponse({ + body: { + found: true, + status: 200, + _source: indexDocumentSource, + _primary_term: 1, + _seq_no: 456, + }, + }) + ); await expect(sessionIndex.get('some-sid')).resolves.toEqual({ ...indexDocumentSource, @@ -626,19 +639,20 @@ describe('Session index', () => { metadata: { primaryTerm: 1, sequenceNumber: 456 }, }); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('get', { - id: 'some-sid', - ignore: [404], - index: indexName, - }); + expect(mockElasticsearchClient.get).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.get).toHaveBeenCalledWith( + { id: 'some-sid', index: indexName }, + { ignore: [404] } + ); }); }); describe('#create', () => { it('throws if call to Elasticsearch fails', async () => { - const failureReason = new Error('Uh oh.'); - mockClusterClient.callAsInternalUser.mockRejectedValue(failureReason); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse(securityMock.createApiResponse({ body: { type: 'Uh oh.' } })) + ); + mockElasticsearchClient.create.mockRejectedValue(failureReason); await expect( sessionIndex.create({ @@ -653,10 +667,9 @@ describe('Session index', () => { }); it('properly stores session value in the index', async () => { - mockClusterClient.callAsInternalUser.mockResolvedValue({ - _primary_term: 321, - _seq_no: 654, - }); + mockElasticsearchClient.create.mockResolvedValue( + securityMock.createApiResponse({ body: { _primary_term: 321, _seq_no: 654 } }) + ); const sid = 'some-long-sid'; const sessionValue = { @@ -673,8 +686,8 @@ describe('Session index', () => { metadata: { primaryTerm: 321, sequenceNumber: 654 }, }); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('create', { + expect(mockElasticsearchClient.create).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.create).toHaveBeenCalledWith({ id: sid, index: indexName, body: sessionValue, @@ -685,8 +698,10 @@ describe('Session index', () => { describe('#update', () => { it('throws if call to Elasticsearch fails', async () => { - const failureReason = new Error('Uh oh.'); - mockClusterClient.callAsInternalUser.mockRejectedValue(failureReason); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse(securityMock.createApiResponse({ body: { type: 'Uh oh.' } })) + ); + mockElasticsearchClient.index.mockRejectedValue(failureReason); await expect(sessionIndex.update(sessionIndexMock.createValue())).rejects.toBe(failureReason); }); @@ -700,21 +715,20 @@ describe('Session index', () => { content: 'some-updated-encrypted-content', }; - mockClusterClient.callAsInternalUser.mockImplementation(async (method) => { - if (method === 'get') { - return { + mockElasticsearchClient.get.mockResolvedValue( + securityMock.createApiResponse({ + body: { found: true, status: 200, _source: latestSessionValue, _primary_term: 321, _seq_no: 654, - }; - } - - if (method === 'index') { - return { status: 409 }; - } - }); + }, + }) + ); + mockElasticsearchClient.index.mockResolvedValue( + securityMock.createApiResponse({ statusCode: 409, body: { status: 409 } }) + ); const sid = 'some-long-sid'; const metadata = { primaryTerm: 123, sequenceNumber: 456 }; @@ -732,24 +746,23 @@ describe('Session index', () => { metadata: { primaryTerm: 321, sequenceNumber: 654 }, }); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('index', { - id: sid, - index: indexName, - body: sessionValue, - ifSeqNo: 456, - ifPrimaryTerm: 123, - refresh: 'wait_for', - ignore: [409], - }); + expect(mockElasticsearchClient.index).toHaveBeenCalledWith( + { + id: sid, + index: indexName, + body: sessionValue, + if_seq_no: 456, + if_primary_term: 123, + refresh: 'wait_for', + }, + { ignore: [409] } + ); }); it('properly stores session value in the index', async () => { - mockClusterClient.callAsInternalUser.mockResolvedValue({ - _primary_term: 321, - _seq_no: 654, - status: 200, - }); + mockElasticsearchClient.index.mockResolvedValue( + securityMock.createApiResponse({ body: { _primary_term: 321, _seq_no: 654, status: 200 } }) + ); const sid = 'some-long-sid'; const metadata = { primaryTerm: 123, sequenceNumber: 456 }; @@ -767,23 +780,27 @@ describe('Session index', () => { metadata: { primaryTerm: 321, sequenceNumber: 654 }, }); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('index', { - id: sid, - index: indexName, - body: sessionValue, - ifSeqNo: 456, - ifPrimaryTerm: 123, - refresh: 'wait_for', - ignore: [409], - }); + expect(mockElasticsearchClient.index).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.index).toHaveBeenCalledWith( + { + id: sid, + index: indexName, + body: sessionValue, + if_seq_no: 456, + if_primary_term: 123, + refresh: 'wait_for', + }, + { ignore: [409] } + ); }); }); describe('#clear', () => { it('throws if call to Elasticsearch fails', async () => { - const failureReason = new Error('Uh oh.'); - mockClusterClient.callAsInternalUser.mockRejectedValue(failureReason); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse(securityMock.createApiResponse({ body: { type: 'Uh oh.' } })) + ); + mockElasticsearchClient.delete.mockRejectedValue(failureReason); await expect(sessionIndex.clear('some-long-sid')).rejects.toBe(failureReason); }); @@ -791,13 +808,11 @@ describe('Session index', () => { it('properly removes session value from the index', async () => { await sessionIndex.clear('some-long-sid'); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('delete', { - id: 'some-long-sid', - index: indexName, - refresh: 'wait_for', - ignore: [404], - }); + expect(mockElasticsearchClient.delete).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.delete).toHaveBeenCalledWith( + { id: 'some-long-sid', index: indexName, refresh: 'wait_for' }, + { ignore: [404] } + ); }); }); }); diff --git a/x-pack/plugins/security/server/session_management/session_index.ts b/x-pack/plugins/security/server/session_management/session_index.ts index 45b2f4489c195d..13250531d391eb 100644 --- a/x-pack/plugins/security/server/session_management/session_index.ts +++ b/x-pack/plugins/security/server/session_management/session_index.ts @@ -4,12 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import type { ILegacyClusterClient, Logger } from '../../../../../src/core/server'; +import type { ElasticsearchClient, Logger } from '../../../../../src/core/server'; import type { AuthenticationProvider } from '../../common/model'; import type { ConfigType } from '../config'; export interface SessionIndexOptions { - readonly clusterClient: ILegacyClusterClient; + readonly elasticsearchClient: ElasticsearchClient; readonly kibanaIndexName: string; readonly config: Pick; readonly logger: Logger; @@ -137,11 +137,10 @@ export class SessionIndex { */ async get(sid: string) { try { - const response = await this.options.clusterClient.callAsInternalUser('get', { - id: sid, - ignore: [404], - index: this.indexName, - }); + const { body: response } = await this.options.elasticsearchClient.get( + { id: sid, index: this.indexName }, + { ignore: [404] } + ); const docNotFound = response.found === false; const indexNotFound = response.status === 404; @@ -176,9 +175,8 @@ export class SessionIndex { const { sid, ...sessionValueToStore } = sessionValue; try { const { - _primary_term: primaryTerm, - _seq_no: sequenceNumber, - } = await this.options.clusterClient.callAsInternalUser('create', { + body: { _primary_term: primaryTerm, _seq_no: sequenceNumber }, + } = await this.options.elasticsearchClient.create({ id: sid, // We cannot control whether index is created automatically during this operation or not. // But we can reduce probability of getting into a weird state when session is being created @@ -203,15 +201,17 @@ export class SessionIndex { async update(sessionValue: Readonly) { const { sid, metadata, ...sessionValueToStore } = sessionValue; try { - const response = await this.options.clusterClient.callAsInternalUser('index', { - id: sid, - index: this.indexName, - body: sessionValueToStore, - ifSeqNo: metadata.sequenceNumber, - ifPrimaryTerm: metadata.primaryTerm, - refresh: 'wait_for', - ignore: [409], - }); + const { body: response } = await this.options.elasticsearchClient.index( + { + id: sid, + index: this.indexName, + body: sessionValueToStore, + if_seq_no: metadata.sequenceNumber, + if_primary_term: metadata.primaryTerm, + refresh: 'wait_for', + }, + { ignore: [409] } + ); // We don't want to override changes that were made after we fetched session value or // re-create it if has been deleted already. If we detect such a case we discard changes and @@ -242,12 +242,10 @@ export class SessionIndex { try { // We don't specify primary term and sequence number as delete should always take precedence // over any updates that could happen in the meantime. - await this.options.clusterClient.callAsInternalUser('delete', { - id: sid, - index: this.indexName, - refresh: 'wait_for', - ignore: [404], - }); + await this.options.elasticsearchClient.delete( + { id: sid, index: this.indexName, refresh: 'wait_for' }, + { ignore: [404] } + ); } catch (err) { this.options.logger.error(`Failed to clear session value: ${err.message}`); throw err; @@ -267,10 +265,11 @@ export class SessionIndex { // Check if required index template exists. let indexTemplateExists = false; try { - indexTemplateExists = await this.options.clusterClient.callAsInternalUser( - 'indices.existsTemplate', - { name: sessionIndexTemplateName } - ); + indexTemplateExists = ( + await this.options.elasticsearchClient.indices.existsTemplate({ + name: sessionIndexTemplateName, + }) + ).body; } catch (err) { this.options.logger.error( `Failed to check if session index template exists: ${err.message}` @@ -283,7 +282,7 @@ export class SessionIndex { this.options.logger.debug('Session index template already exists.'); } else { try { - await this.options.clusterClient.callAsInternalUser('indices.putTemplate', { + await this.options.elasticsearchClient.indices.putTemplate({ name: sessionIndexTemplateName, body: getSessionIndexTemplate(this.indexName), }); @@ -298,9 +297,9 @@ export class SessionIndex { // always enabled, so we create session index explicitly. let indexExists = false; try { - indexExists = await this.options.clusterClient.callAsInternalUser('indices.exists', { - index: this.indexName, - }); + indexExists = ( + await this.options.elasticsearchClient.indices.exists({ index: this.indexName }) + ).body; } catch (err) { this.options.logger.error(`Failed to check if session index exists: ${err.message}`); return reject(err); @@ -311,9 +310,7 @@ export class SessionIndex { this.options.logger.debug('Session index already exists.'); } else { try { - await this.options.clusterClient.callAsInternalUser('indices.create', { - index: this.indexName, - }); + await this.options.elasticsearchClient.indices.create({ index: this.indexName }); this.options.logger.debug('Successfully created session index.'); } catch (err) { // There can be a race condition if index is created by another Kibana instance. @@ -399,12 +396,14 @@ export class SessionIndex { } try { - const response = await this.options.clusterClient.callAsInternalUser('deleteByQuery', { - index: this.indexName, - refresh: 'wait_for', - ignore: [409, 404], - body: { query: { bool: { should: deleteQueries } } }, - }); + const { body: response } = await this.options.elasticsearchClient.deleteByQuery( + { + index: this.indexName, + refresh: true, + body: { query: { bool: { should: deleteQueries } } }, + }, + { ignore: [409, 404] } + ); if (response.deleted > 0) { this.options.logger.debug( diff --git a/x-pack/plugins/security/server/session_management/session_management_service.test.ts b/x-pack/plugins/security/server/session_management/session_management_service.test.ts index 08d4c491d15560..d3e5c876c99742 100644 --- a/x-pack/plugins/security/server/session_management/session_management_service.test.ts +++ b/x-pack/plugins/security/server/session_management/session_management_service.test.ts @@ -21,7 +21,7 @@ import { loggingSystemMock, } from '../../../../../src/core/server/mocks'; import { taskManagerMock } from '../../../task_manager/server/mocks'; -import { TaskManagerStartContract } from '../../../task_manager/server'; +import { TaskManagerStartContract, TaskRunCreatorFunction } from '../../../task_manager/server'; describe('SessionManagementService', () => { let service: SessionManagementService; @@ -30,21 +30,19 @@ describe('SessionManagementService', () => { }); describe('setup()', () => { - it('exposes proper contract', () => { + it('registers cleanup task', () => { const mockCoreSetup = coreMock.createSetup(); const mockTaskManager = taskManagerMock.createSetup(); expect( service.setup({ - clusterClient: elasticsearchServiceMock.createLegacyClusterClient(), http: mockCoreSetup.http, config: createConfig(ConfigSchema.validate({}), loggingSystemMock.createLogger(), { isTLSEnabled: false, }), - kibanaIndexName: '.kibana', taskManager: mockTaskManager, }) - ).toEqual({ session: expect.any(Session) }); + ).toBeUndefined(); expect(mockTaskManager.registerTaskDefinitions).toHaveBeenCalledTimes(1); expect(mockTaskManager.registerTaskDefinitions).toHaveBeenCalledWith({ @@ -54,60 +52,35 @@ describe('SessionManagementService', () => { }, }); }); - - it('registers proper session index cleanup task runner', () => { - const mockSessionIndexCleanUp = jest.spyOn(SessionIndex.prototype, 'cleanUp'); - const mockTaskManager = taskManagerMock.createSetup(); - - const mockClusterClient = elasticsearchServiceMock.createLegacyClusterClient(); - mockClusterClient.callAsInternalUser.mockResolvedValue({}); - service.setup({ - clusterClient: mockClusterClient, - http: coreMock.createSetup().http, - config: createConfig(ConfigSchema.validate({}), loggingSystemMock.createLogger(), { - isTLSEnabled: false, - }), - kibanaIndexName: '.kibana', - taskManager: mockTaskManager, - }); - - const [ - [ - { - [SESSION_INDEX_CLEANUP_TASK_NAME]: { createTaskRunner }, - }, - ], - ] = mockTaskManager.registerTaskDefinitions.mock.calls; - expect(mockSessionIndexCleanUp).not.toHaveBeenCalled(); - - const runner = createTaskRunner({} as any); - runner.run(); - expect(mockSessionIndexCleanUp).toHaveBeenCalledTimes(1); - - runner.run(); - expect(mockSessionIndexCleanUp).toHaveBeenCalledTimes(2); - }); }); describe('start()', () => { let mockSessionIndexInitialize: jest.SpyInstance; let mockTaskManager: jest.Mocked; + let sessionCleanupTaskRunCreator: TaskRunCreatorFunction; beforeEach(() => { mockSessionIndexInitialize = jest.spyOn(SessionIndex.prototype, 'initialize'); mockTaskManager = taskManagerMock.createStart(); mockTaskManager.ensureScheduled.mockResolvedValue(undefined as any); - const mockCoreSetup = coreMock.createSetup(); + const mockTaskManagerSetup = taskManagerMock.createSetup(); service.setup({ - clusterClient: elasticsearchServiceMock.createLegacyClusterClient(), - http: mockCoreSetup.http, + http: coreMock.createSetup().http, config: createConfig(ConfigSchema.validate({}), loggingSystemMock.createLogger(), { isTLSEnabled: false, }), - kibanaIndexName: '.kibana', - taskManager: taskManagerMock.createSetup(), + taskManager: mockTaskManagerSetup, }); + + const [ + [ + { + [SESSION_INDEX_CLEANUP_TASK_NAME]: { createTaskRunner }, + }, + ], + ] = mockTaskManagerSetup.registerTaskDefinitions.mock.calls; + sessionCleanupTaskRunCreator = createTaskRunner; }); afterEach(() => { @@ -117,13 +90,43 @@ describe('SessionManagementService', () => { it('exposes proper contract', () => { const mockStatusSubject = new Subject(); expect( - service.start({ online$: mockStatusSubject.asObservable(), taskManager: mockTaskManager }) - ).toBeUndefined(); + service.start({ + elasticsearchClient: elasticsearchServiceMock.createElasticsearchClient(), + kibanaIndexName: '.kibana', + online$: mockStatusSubject.asObservable(), + taskManager: mockTaskManager, + }) + ).toEqual({ session: expect.any(Session) }); + }); + + it('registers proper session index cleanup task runner', () => { + const mockSessionIndexCleanUp = jest.spyOn(SessionIndex.prototype, 'cleanUp'); + const mockStatusSubject = new Subject(); + service.start({ + elasticsearchClient: elasticsearchServiceMock.createElasticsearchClient(), + kibanaIndexName: '.kibana', + online$: mockStatusSubject.asObservable(), + taskManager: mockTaskManager, + }); + + expect(mockSessionIndexCleanUp).not.toHaveBeenCalled(); + + const runner = sessionCleanupTaskRunCreator({} as any); + runner.run(); + expect(mockSessionIndexCleanUp).toHaveBeenCalledTimes(1); + + runner.run(); + expect(mockSessionIndexCleanUp).toHaveBeenCalledTimes(2); }); it('initializes session index and schedules session index cleanup task when Elasticsearch goes online', async () => { const mockStatusSubject = new Subject(); - service.start({ online$: mockStatusSubject.asObservable(), taskManager: mockTaskManager }); + service.start({ + elasticsearchClient: elasticsearchServiceMock.createElasticsearchClient(), + kibanaIndexName: '.kibana', + online$: mockStatusSubject.asObservable(), + taskManager: mockTaskManager, + }); // ES isn't online yet. expect(mockSessionIndexInitialize).not.toHaveBeenCalled(); @@ -155,7 +158,12 @@ describe('SessionManagementService', () => { it('removes old cleanup task if cleanup interval changes', async () => { const mockStatusSubject = new Subject(); - service.start({ online$: mockStatusSubject.asObservable(), taskManager: mockTaskManager }); + service.start({ + elasticsearchClient: elasticsearchServiceMock.createElasticsearchClient(), + kibanaIndexName: '.kibana', + online$: mockStatusSubject.asObservable(), + taskManager: mockTaskManager, + }); mockTaskManager.get.mockResolvedValue({ schedule: { interval: '2000s' } } as any); @@ -185,7 +193,12 @@ describe('SessionManagementService', () => { it('does not remove old cleanup task if cleanup interval does not change', async () => { const mockStatusSubject = new Subject(); - service.start({ online$: mockStatusSubject.asObservable(), taskManager: mockTaskManager }); + service.start({ + elasticsearchClient: elasticsearchServiceMock.createElasticsearchClient(), + kibanaIndexName: '.kibana', + online$: mockStatusSubject.asObservable(), + taskManager: mockTaskManager, + }); mockTaskManager.get.mockResolvedValue({ schedule: { interval: '3600s' } } as any); @@ -206,7 +219,12 @@ describe('SessionManagementService', () => { it('schedules retry if index initialization fails', async () => { const mockStatusSubject = new Subject(); - service.start({ online$: mockStatusSubject.asObservable(), taskManager: mockTaskManager }); + service.start({ + elasticsearchClient: elasticsearchServiceMock.createElasticsearchClient(), + kibanaIndexName: '.kibana', + online$: mockStatusSubject.asObservable(), + taskManager: mockTaskManager, + }); mockSessionIndexInitialize.mockRejectedValue(new Error('ugh :/')); @@ -237,7 +255,12 @@ describe('SessionManagementService', () => { it('schedules retry if cleanup task registration fails', async () => { const mockStatusSubject = new Subject(); - service.start({ online$: mockStatusSubject.asObservable(), taskManager: mockTaskManager }); + service.start({ + elasticsearchClient: elasticsearchServiceMock.createElasticsearchClient(), + kibanaIndexName: '.kibana', + online$: mockStatusSubject.asObservable(), + taskManager: mockTaskManager, + }); mockTaskManager.ensureScheduled.mockRejectedValue(new Error('ugh :/')); @@ -277,12 +300,10 @@ describe('SessionManagementService', () => { const mockCoreSetup = coreMock.createSetup(); service.setup({ - clusterClient: elasticsearchServiceMock.createLegacyClusterClient(), http: mockCoreSetup.http, config: createConfig(ConfigSchema.validate({}), loggingSystemMock.createLogger(), { isTLSEnabled: false, }), - kibanaIndexName: '.kibana', taskManager: taskManagerMock.createSetup(), }); }); @@ -293,7 +314,12 @@ describe('SessionManagementService', () => { it('properly unsubscribes from status updates', () => { const mockStatusSubject = new Subject(); - service.start({ online$: mockStatusSubject.asObservable(), taskManager: mockTaskManager }); + service.start({ + elasticsearchClient: elasticsearchServiceMock.createElasticsearchClient(), + kibanaIndexName: '.kibana', + online$: mockStatusSubject.asObservable(), + taskManager: mockTaskManager, + }); service.stop(); diff --git a/x-pack/plugins/security/server/session_management/session_management_service.ts b/x-pack/plugins/security/server/session_management/session_management_service.ts index fc2e85d683d586..6bd9d8cb3a8fe7 100644 --- a/x-pack/plugins/security/server/session_management/session_management_service.ts +++ b/x-pack/plugins/security/server/session_management/session_management_service.ts @@ -6,8 +6,8 @@ import { Observable, Subscription } from 'rxjs'; import { + ElasticsearchClient, HttpServiceSetup, - ILegacyClusterClient, Logger, SavedObjectsErrorHelpers, } from '../../../../../src/core/server'; @@ -21,17 +21,17 @@ import { Session } from './session'; export interface SessionManagementServiceSetupParams { readonly http: Pick; readonly config: ConfigType; - readonly clusterClient: ILegacyClusterClient; - readonly kibanaIndexName: string; readonly taskManager: TaskManagerSetupContract; } export interface SessionManagementServiceStartParams { + readonly elasticsearchClient: ElasticsearchClient; + readonly kibanaIndexName: string; readonly online$: Observable; readonly taskManager: TaskManagerStartContract; } -export interface SessionManagementServiceSetup { +export interface SessionManagementServiceStart { readonly session: Session; } @@ -46,34 +46,22 @@ export const SESSION_INDEX_CLEANUP_TASK_NAME = 'session_cleanup'; export class SessionManagementService { private statusSubscription?: Subscription; private sessionIndex!: SessionIndex; + private sessionCookie!: SessionCookie; private config!: ConfigType; private isCleanupTaskScheduled = false; constructor(private readonly logger: Logger) {} - setup({ - config, - clusterClient, - http, - kibanaIndexName, - taskManager, - }: SessionManagementServiceSetupParams): SessionManagementServiceSetup { + setup({ config, http, taskManager }: SessionManagementServiceSetupParams) { this.config = config; - const sessionCookie = new SessionCookie({ + this.sessionCookie = new SessionCookie({ config, createCookieSessionStorageFactory: http.createCookieSessionStorageFactory, serverBasePath: http.basePath.serverBasePath || '/', logger: this.logger.get('cookie'), }); - this.sessionIndex = new SessionIndex({ - config, - clusterClient, - kibanaIndexName, - logger: this.logger.get('index'), - }); - // Register task that will perform periodic session index cleanup. taskManager.registerTaskDefinitions({ [SESSION_INDEX_CLEANUP_TASK_NAME]: { @@ -81,18 +69,21 @@ export class SessionManagementService { createTaskRunner: () => ({ run: () => this.sessionIndex.cleanUp() }), }, }); - - return { - session: new Session({ - logger: this.logger, - sessionCookie, - sessionIndex: this.sessionIndex, - config, - }), - }; } - start({ online$, taskManager }: SessionManagementServiceStartParams) { + start({ + elasticsearchClient, + kibanaIndexName, + online$, + taskManager, + }: SessionManagementServiceStartParams): SessionManagementServiceStart { + this.sessionIndex = new SessionIndex({ + config: this.config, + elasticsearchClient, + kibanaIndexName, + logger: this.logger.get('index'), + }); + this.statusSubscription = online$.subscribe(async ({ scheduleRetry }) => { try { await Promise.all([this.sessionIndex.initialize(), this.scheduleCleanupTask(taskManager)]); @@ -100,6 +91,15 @@ export class SessionManagementService { scheduleRetry(); } }); + + return { + session: new Session({ + logger: this.logger, + sessionCookie: this.sessionCookie, + sessionIndex: this.sessionIndex, + config: this.config, + }), + }; } stop() { From cacce7a866f8eeb8b878c4427d344f34d1517460 Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 21 Jan 2021 14:51:29 -0700 Subject: [PATCH 11/16] [ftr/verbose_instance] check for `.finally()` before using it (#88998) Co-authored-by: spalger Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../lib/providers/verbose_instance.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/kbn-test/src/functional_test_runner/lib/providers/verbose_instance.ts b/packages/kbn-test/src/functional_test_runner/lib/providers/verbose_instance.ts index 248b55d85d8f57..cc2ecad82fb19e 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/providers/verbose_instance.ts +++ b/packages/kbn-test/src/functional_test_runner/lib/providers/verbose_instance.ts @@ -65,7 +65,11 @@ export function createVerboseInstance( } const { returned } = result; - if (returned && typeof returned.then === 'function') { + if ( + returned && + typeof returned.then === 'function' && + typeof returned.finally === 'function' + ) { return returned.finally(() => { log.indent(-2); }); From c495093f76e3ee899e9298970811b82e0627745f Mon Sep 17 00:00:00 2001 From: Constance Date: Thu, 21 Jan 2021 14:39:14 -0800 Subject: [PATCH 12/16] [App Search] Move generateEnginePath out from EngineLogic values to its own helper (#89022) * [Feedback] Move generateEnginePath to its own standalone helper - instead of living inside EngineLogic.values - I forgot Kea lets us do this now! * Update all components using generateEngineRouter to import helper directly --- .../app_search/__mocks__/engine_logic.mock.ts | 12 ++++---- .../analytics/analytics_router.test.tsx | 4 +-- .../components/analytics/analytics_router.tsx | 5 +--- .../document_creation_buttons.test.tsx | 5 ++-- .../document_creation_buttons.tsx | 5 ++-- .../documents/document_detail_logic.ts | 6 ++-- .../components/engine/engine_logic.test.ts | 23 --------------- .../components/engine/engine_logic.ts | 11 -------- .../components/engine/engine_nav.tsx | 3 +- .../app_search/components/engine/index.ts | 1 + .../components/engine/utils.test.ts | 28 +++++++++++++++++++ .../app_search/components/engine/utils.ts | 17 +++++++++++ .../components/recent_api_logs.test.tsx | 4 +-- .../components/recent_api_logs.tsx | 5 +--- .../components/total_charts.test.tsx | 3 +- .../components/total_charts.tsx | 3 +- 16 files changed, 65 insertions(+), 70 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/utils.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/utils.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/__mocks__/engine_logic.mock.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/__mocks__/engine_logic.mock.ts index 5c327f64d77756..6326a41c1d2ca4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/__mocks__/engine_logic.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/__mocks__/engine_logic.mock.ts @@ -8,16 +8,14 @@ import { generatePath } from 'react-router-dom'; export const mockEngineValues = { engineName: 'some-engine', - // Note: using getters allows us to use `this`, which lets tests - // override engineName and still generate correct engine names - get generateEnginePath() { - return jest.fn((path, pathParams = {}) => - generatePath(path, { engineName: this.engineName, ...pathParams }) - ); - }, engine: {}, }; +export const mockGenerateEnginePath = jest.fn((path, pathParams = {}) => + generatePath(path, { engineName: mockEngineValues.engineName, ...pathParams }) +); + jest.mock('../components/engine', () => ({ EngineLogic: { values: mockEngineValues }, + generateEnginePath: mockGenerateEnginePath, })); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.test.tsx index aea107a137da1e..2cc6ff32d0ad99 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.test.tsx @@ -4,8 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { setMockValues } from '../../../__mocks__'; -import { mockEngineValues } from '../../__mocks__'; +import '../../__mocks__/engine_logic.mock'; import React from 'react'; import { shallow } from 'enzyme'; @@ -16,7 +15,6 @@ import { AnalyticsRouter } from './'; describe('AnalyticsRouter', () => { // Detailed route testing is better done via E2E tests it('renders', () => { - setMockValues(mockEngineValues); const wrapper = shallow(); expect(wrapper.find(Switch)).toHaveLength(1); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.tsx index 60c0f2a3fd3e87..f549a1a8d9091d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.tsx @@ -6,7 +6,6 @@ import React from 'react'; import { Route, Switch, Redirect } from 'react-router-dom'; -import { useValues } from 'kea'; import { APP_SEARCH_PLUGIN } from '../../../../../common/constants'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; @@ -22,7 +21,7 @@ import { ENGINE_ANALYTICS_QUERY_DETAILS_PATH, ENGINE_ANALYTICS_QUERY_DETAIL_PATH, } from '../../routes'; -import { EngineLogic } from '../engine'; +import { generateEnginePath } from '../engine'; import { ANALYTICS_TITLE, @@ -46,8 +45,6 @@ interface Props { engineBreadcrumb: BreadcrumbTrail; } export const AnalyticsRouter: React.FC = ({ engineBreadcrumb }) => { - const { generateEnginePath } = useValues(EngineLogic); - const ANALYTICS_BREADCRUMB = [...engineBreadcrumb, ANALYTICS_TITLE]; return ( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.test.tsx index d8684355c1a81b..bd4d088bc1d8a0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.test.tsx @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { setMockValues, setMockActions } from '../../../__mocks__/kea.mock'; -import { mockEngineValues } from '../../__mocks__'; +import { setMockActions } from '../../../__mocks__/kea.mock'; +import '../../__mocks__/engine_logic.mock'; import React from 'react'; import { shallow } from 'enzyme'; @@ -21,7 +21,6 @@ describe('DocumentCreationButtons', () => { beforeEach(() => { jest.clearAllMocks(); - setMockValues(mockEngineValues); setMockActions(actions); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.tsx index 93c93224b59822..3a53b3c83d9eb8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.tsx @@ -5,7 +5,7 @@ */ import React from 'react'; -import { useActions, useValues } from 'kea'; +import { useActions } from 'kea'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -22,7 +22,7 @@ import { import { EuiCardTo } from '../../../shared/react_router_helpers'; import { DOCS_PREFIX, ENGINE_CRAWLER_PATH } from '../../routes'; -import { EngineLogic } from '../engine'; +import { generateEnginePath } from '../engine'; import { DocumentCreationLogic } from './'; @@ -33,7 +33,6 @@ interface Props { export const DocumentCreationButtons: React.FC = ({ disabled = false }) => { const { openDocumentCreation } = useActions(DocumentCreationLogic); - const { generateEnginePath } = useValues(EngineLogic); const crawlerLink = generateEnginePath(ENGINE_CRAWLER_PATH); return ( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.ts index b8d67ac56b3a28..8141ba73d418eb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.ts @@ -12,7 +12,7 @@ import { KibanaLogic } from '../../../shared/kibana'; import { HttpLogic } from '../../../shared/http'; import { ENGINE_DOCUMENTS_PATH } from '../../routes'; -import { EngineLogic } from '../engine'; +import { EngineLogic, generateEnginePath } from '../engine'; import { FieldDetails } from './types'; @@ -52,7 +52,7 @@ export const DocumentDetailLogic = kea({ }), listeners: ({ actions }) => ({ getDocumentDetails: async ({ documentId }) => { - const { engineName, generateEnginePath } = EngineLogic.values; + const { engineName } = EngineLogic.values; const { navigateToUrl } = KibanaLogic.values; try { @@ -70,7 +70,7 @@ export const DocumentDetailLogic = kea({ } }, deleteDocument: async ({ documentId }) => { - const { engineName, generateEnginePath } = EngineLogic.values; + const { engineName } = EngineLogic.values; const { navigateToUrl } = KibanaLogic.values; const CONFIRM_DELETE = i18n.translate( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts index 32c3382cf187a6..48cbaeef70c1ae 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts @@ -36,7 +36,6 @@ describe('EngineLogic', () => { dataLoading: true, engine: {}, engineName: '', - generateEnginePath: expect.any(Function), isMetaEngine: false, isSampleEngine: false, hasSchemaConflicts: false, @@ -198,28 +197,6 @@ describe('EngineLogic', () => { }); describe('selectors', () => { - describe('generateEnginePath', () => { - it('returns helper function that generates paths with engineName prefilled', () => { - mount({ engineName: 'hello-world' }); - - const generatedPath = EngineLogic.values.generateEnginePath('/engines/:engineName/example'); - expect(generatedPath).toEqual('/engines/hello-world/example'); - }); - - it('allows overriding engineName and filling other params', () => { - mount({ engineName: 'lorem-ipsum' }); - - const generatedPath = EngineLogic.values.generateEnginePath( - '/engines/:engineName/foo/:bar', - { - engineName: 'dolor-sit', - bar: 'baz', - } - ); - expect(generatedPath).toEqual('/engines/dolor-sit/foo/baz'); - }); - }); - describe('isSampleEngine', () => { it('should be set based on engine.sample', () => { const mockSampleEngine = { ...mockEngineData, sample: true }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.ts index 04d06b596080af..9f3fe721b74de2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.ts @@ -5,7 +5,6 @@ */ import { kea, MakeLogicType } from 'kea'; -import { generatePath } from 'react-router-dom'; import { HttpLogic } from '../../../shared/http'; @@ -16,7 +15,6 @@ interface EngineValues { dataLoading: boolean; engine: Partial; engineName: string; - generateEnginePath: Function; isMetaEngine: boolean; isSampleEngine: boolean; hasSchemaConflicts: boolean; @@ -78,15 +76,6 @@ export const EngineLogic = kea>({ ], }, selectors: ({ selectors }) => ({ - generateEnginePath: [ - () => [selectors.engineName], - (engineName) => { - const generateEnginePath = (path: string, pathParams: object = {}) => { - return generatePath(path, { engineName, ...pathParams }); - }; - return generateEnginePath; - }, - ], isMetaEngine: [() => [selectors.engine], (engine) => engine?.type === 'meta'], isSampleEngine: [() => [selectors.engine], (engine) => !!engine?.sample], hasSchemaConflicts: [ diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx index fd30e04d349329..0e5a7d56e90653 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx @@ -40,7 +40,7 @@ import { RESULT_SETTINGS_TITLE } from '../result_settings'; import { SEARCH_UI_TITLE } from '../search_ui'; import { API_LOGS_TITLE } from '../api_logs'; -import { EngineLogic } from './'; +import { EngineLogic, generateEnginePath } from './'; import { EngineDetails } from './types'; import './engine_nav.scss'; @@ -64,7 +64,6 @@ export const EngineNav: React.FC = () => { const { engineName, - generateEnginePath, dataLoading, isSampleEngine, isMetaEngine, diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/index.ts index 4e7d81f73fb8de..7846eb9d03b716 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/index.ts @@ -7,3 +7,4 @@ export { EngineRouter } from './engine_router'; export { EngineNav } from './engine_nav'; export { EngineLogic } from './engine_logic'; +export { generateEnginePath } from './utils'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/utils.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/utils.test.ts new file mode 100644 index 00000000000000..cff4065c13f5eb --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/utils.test.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mockEngineValues } from '../../__mocks__'; + +import { generateEnginePath } from './utils'; + +describe('generateEnginePath', () => { + mockEngineValues.engineName = 'hello-world'; + + it('generates paths with engineName filled from state', () => { + expect(generateEnginePath('/engines/:engineName/example')).toEqual( + '/engines/hello-world/example' + ); + }); + + it('allows overriding engineName and filling other params', () => { + expect( + generateEnginePath('/engines/:engineName/foo/:bar', { + engineName: 'override', + bar: 'baz', + }) + ).toEqual('/engines/override/foo/baz'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/utils.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/utils.ts new file mode 100644 index 00000000000000..b7efcbb6e6b27a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/utils.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { generatePath } from 'react-router-dom'; + +import { EngineLogic } from './'; + +/** + * Generate a path with engineName automatically filled from EngineLogic state + */ +export const generateEnginePath = (path: string, pathParams: object = {}) => { + const { engineName } = EngineLogic.values; + return generatePath(path, { engineName, ...pathParams }); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/recent_api_logs.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/recent_api_logs.test.tsx index 9da63ca639bbfd..d7d22cafee4326 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/recent_api_logs.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/recent_api_logs.test.tsx @@ -4,8 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { setMockValues } from '../../../../__mocks__/kea.mock'; -import { mockEngineValues } from '../../../__mocks__'; +import '../../../__mocks__/engine_logic.mock'; import React from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; @@ -19,7 +18,6 @@ describe('RecentApiLogs', () => { beforeAll(() => { jest.clearAllMocks(); - setMockValues(mockEngineValues); wrapper = shallow(); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/recent_api_logs.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/recent_api_logs.tsx index 19c931cefc1e39..207666ef674664 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/recent_api_logs.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/recent_api_logs.tsx @@ -5,7 +5,6 @@ */ import React from 'react'; -import { useValues } from 'kea'; import { EuiPageContent, @@ -17,14 +16,12 @@ import { import { EuiButtonTo } from '../../../../shared/react_router_helpers'; import { ENGINE_API_LOGS_PATH } from '../../../routes'; -import { EngineLogic } from '../../engine'; +import { generateEnginePath } from '../../engine'; import { RECENT_API_EVENTS } from '../../api_logs/constants'; import { VIEW_API_LOGS } from '../constants'; export const RecentApiLogs: React.FC = () => { - const { generateEnginePath } = useValues(EngineLogic); - return ( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/total_charts.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/total_charts.test.tsx index 98718dea7130f6..14fb19b8ca2bec 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/total_charts.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/total_charts.test.tsx @@ -5,7 +5,7 @@ */ import { setMockValues } from '../../../../__mocks__/kea.mock'; -import { mockEngineValues } from '../../../__mocks__'; +import '../../../__mocks__/engine_logic.mock'; import React from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; @@ -21,7 +21,6 @@ describe('TotalCharts', () => { beforeAll(() => { jest.clearAllMocks(); setMockValues({ - ...mockEngineValues, startDate: '1970-01-01', queriesPerDay: [0, 1, 2, 3, 5, 10, 50], operationsPerDay: [0, 0, 0, 0, 0, 0, 0], diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/total_charts.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/total_charts.tsx index 02453cc8a150f0..e8454cdc95ebc3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/total_charts.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/total_charts.tsx @@ -20,7 +20,7 @@ import { import { EuiButtonTo } from '../../../../shared/react_router_helpers'; import { ENGINE_ANALYTICS_PATH, ENGINE_API_LOGS_PATH } from '../../../routes'; -import { EngineLogic } from '../../engine'; +import { generateEnginePath } from '../../engine'; import { TOTAL_QUERIES, TOTAL_API_OPERATIONS } from '../../analytics/constants'; import { VIEW_ANALYTICS, VIEW_API_LOGS, LAST_7_DAYS } from '../constants'; @@ -28,7 +28,6 @@ import { AnalyticsChart, convertToChartData } from '../../analytics'; import { EngineOverviewLogic } from '../'; export const TotalCharts: React.FC = () => { - const { generateEnginePath } = useValues(EngineLogic); const { startDate, queriesPerDay, operationsPerDay } = useValues(EngineOverviewLogic); return ( From 4281a347c6b3bb1304c8cf70ba82a34c466b2ae5 Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Thu, 21 Jan 2021 17:32:18 -0600 Subject: [PATCH 13/16] [Workplace Search] Add tests for remaining Sources components (#89026) * Remove history params We already replace the history.push functionality with KibanaLogic.values.navigateToUrl but the history object was still being passed around. * Add org sources container tests * Add tests for source router * Clean up leftover history imports * Add tests for SourcesRouter * Quick refactor for cleaner existence check Optional chaining FTW * Refactor to simplify setInterval logic This commit does a refactor to move the logic for polling for status to the logic file. In doing this I realized that we were intializing sources in the SourcesView, when we are actually already initializing sources in the components that use this, which are OrganizationSources and PrivateSources, the top-level containers. Because of this, I was able to remove the useEffect entireley, as the flash messages are cleared between page transitions in Kibana and the initialization of the sources ahppens in the containers. * Add tests for SourcesView * Fix type issue --- .../organization_sources.test.tsx | 63 +++++++++ .../content_sources/organization_sources.tsx | 3 +- .../views/content_sources/source_logic.ts | 4 +- .../content_sources/source_router.test.tsx | 120 ++++++++++++++++++ .../views/content_sources/source_router.tsx | 6 +- .../views/content_sources/sources_logic.ts | 28 +++- .../content_sources/sources_router.test.tsx | 60 +++++++++ .../content_sources/sources_view.test.tsx | 64 ++++++++++ .../views/content_sources/sources_view.tsx | 23 +--- 9 files changed, 337 insertions(+), 34 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/organization_sources.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_view.test.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/organization_sources.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/organization_sources.test.tsx new file mode 100644 index 00000000000000..1050150028aec3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/organization_sources.test.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../__mocks__/shallow_useeffect.mock'; + +import { setMockValues, setMockActions } from '../../../__mocks__'; + +import { shallow } from 'enzyme'; + +import React from 'react'; +import { Redirect } from 'react-router-dom'; + +import { contentSources } from '../../__mocks__/content_sources.mock'; + +import { Loading } from '../../../shared/loading'; +import { SourcesTable } from '../../components/shared/sources_table'; +import { ViewContentHeader } from '../../components/shared/view_content_header'; + +import { ADD_SOURCE_PATH, getSourcesPath } from '../../routes'; + +import { OrganizationSources } from './organization_sources'; + +describe('OrganizationSources', () => { + const initializeSources = jest.fn(); + const setSourceSearchability = jest.fn(); + + const mockValues = { + contentSources, + dataLoading: false, + }; + + beforeEach(() => { + setMockActions({ + initializeSources, + setSourceSearchability, + }); + setMockValues({ ...mockValues }); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(SourcesTable)).toHaveLength(1); + expect(wrapper.find(ViewContentHeader)).toHaveLength(1); + }); + + it('returns loading when loading', () => { + setMockValues({ ...mockValues, dataLoading: true }); + const wrapper = shallow(); + + expect(wrapper.find(Loading)).toHaveLength(1); + }); + + it('returns redirect when no sources', () => { + setMockValues({ ...mockValues, contentSources: [] }); + const wrapper = shallow(); + + expect(wrapper.find(Redirect).prop('to')).toEqual(getSourcesPath(ADD_SOURCE_PATH, true)); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/organization_sources.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/organization_sources.tsx index 880df3d086cccc..fdb536dd797714 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/organization_sources.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/organization_sources.tsx @@ -27,10 +27,11 @@ const ORG_HEADER_DESCRIPTION = 'Organization sources are available to the entire organization and can be assigned to specific user groups.'; export const OrganizationSources: React.FC = () => { - const { initializeSources, setSourceSearchability } = useActions(SourcesLogic); + const { initializeSources, setSourceSearchability, resetSourcesState } = useActions(SourcesLogic); useEffect(() => { initializeSources(); + return resetSourcesState; }, []); const { dataLoading, contentSources } = useValues(SourcesLogic); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts index fe958db9d02326..2de70009c56a20 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts @@ -39,7 +39,7 @@ export interface SourceActions { ): { sourceId: string; source: { name: string } }; resetSourceState(): void; removeContentSource(sourceId: string): { sourceId: string }; - initializeSource(sourceId: string, history: object): { sourceId: string; history: object }; + initializeSource(sourceId: string): { sourceId: string }; getSourceConfigData(serviceType: string): { serviceType: string }; setButtonNotLoading(): void; } @@ -88,7 +88,7 @@ export const SourceLogic = kea>({ setSearchResults: (searchResultsResponse: SearchResultsResponse) => searchResultsResponse, setContentFilterValue: (contentFilterValue: string) => contentFilterValue, setActivePage: (activePage: number) => activePage, - initializeSource: (sourceId: string, history: object) => ({ sourceId, history }), + initializeSource: (sourceId: string) => ({ sourceId }), initializeFederatedSummary: (sourceId: string) => ({ sourceId }), searchContentSourceDocuments: (sourceId: string) => ({ sourceId }), updateContentSource: (sourceId: string, source: { name: string }) => ({ sourceId, source }), diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.test.tsx new file mode 100644 index 00000000000000..ac542f57b8fd42 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.test.tsx @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../__mocks__/shallow_useeffect.mock'; + +import { setMockValues, setMockActions } from '../../../__mocks__'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import { useParams } from 'react-router-dom'; + +import { Route, Switch } from 'react-router-dom'; + +import { contentSources } from '../../__mocks__/content_sources.mock'; + +import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; + +import { NAV } from '../../constants'; + +import { Loading } from '../../../shared/loading'; + +import { DisplaySettingsRouter } from './components/display_settings'; +import { Overview } from './components/overview'; +import { Schema } from './components/schema'; +import { SchemaChangeErrors } from './components/schema/schema_change_errors'; +import { SourceContent } from './components/source_content'; +import { SourceSettings } from './components/source_settings'; + +import { SourceRouter } from './source_router'; + +describe('SourceRouter', () => { + const initializeSource = jest.fn(); + const contentSource = contentSources[1]; + const customSource = contentSources[0]; + const mockValues = { + contentSource, + dataLoading: false, + }; + + beforeEach(() => { + setMockActions({ + initializeSource, + }); + setMockValues({ ...mockValues }); + (useParams as jest.Mock).mockImplementationOnce(() => ({ + sourceId: '1', + })); + }); + + it('returns Loading when loading', () => { + setMockValues({ ...mockValues, dataLoading: true }); + const wrapper = shallow(); + + expect(wrapper.find(Loading)).toHaveLength(1); + }); + + it('renders source routes (standard)', () => { + const wrapper = shallow(); + + expect(wrapper.find(Overview)).toHaveLength(1); + expect(wrapper.find(SourceSettings)).toHaveLength(1); + expect(wrapper.find(SourceContent)).toHaveLength(1); + expect(wrapper.find(Switch)).toHaveLength(1); + expect(wrapper.find(Route)).toHaveLength(3); + }); + + it('renders source routes (custom)', () => { + setMockValues({ ...mockValues, contentSource: customSource }); + const wrapper = shallow(); + + expect(wrapper.find(DisplaySettingsRouter)).toHaveLength(1); + expect(wrapper.find(Schema)).toHaveLength(1); + expect(wrapper.find(SchemaChangeErrors)).toHaveLength(1); + expect(wrapper.find(Route)).toHaveLength(6); + }); + + it('handles breadcrumbs while loading (standard)', () => { + setMockValues({ + ...mockValues, + contentSource: {}, + }); + + const loadingBreadcrumbs = ['Sources', '...']; + + const wrapper = shallow(); + + const overviewBreadCrumb = wrapper.find(SetPageChrome).at(0); + const contentBreadCrumb = wrapper.find(SetPageChrome).at(1); + const settingsBreadCrumb = wrapper.find(SetPageChrome).at(2); + + expect(overviewBreadCrumb.prop('trail')).toEqual([...loadingBreadcrumbs, NAV.OVERVIEW]); + expect(contentBreadCrumb.prop('trail')).toEqual([...loadingBreadcrumbs, NAV.CONTENT]); + expect(settingsBreadCrumb.prop('trail')).toEqual([...loadingBreadcrumbs, NAV.SETTINGS]); + }); + + it('handles breadcrumbs while loading (custom)', () => { + setMockValues({ + ...mockValues, + contentSource: { serviceType: 'custom' }, + }); + + const loadingBreadcrumbs = ['Sources', '...']; + + const wrapper = shallow(); + + const schemaBreadCrumb = wrapper.find(SetPageChrome).at(2); + const schemaErrorsBreadCrumb = wrapper.find(SetPageChrome).at(3); + const displaySettingsBreadCrumb = wrapper.find(SetPageChrome).at(4); + + expect(schemaBreadCrumb.prop('trail')).toEqual([...loadingBreadcrumbs, NAV.SCHEMA]); + expect(schemaErrorsBreadCrumb.prop('trail')).toEqual([...loadingBreadcrumbs, NAV.SCHEMA]); + expect(displaySettingsBreadCrumb.prop('trail')).toEqual([ + ...loadingBreadcrumbs, + NAV.DISPLAY_SETTINGS, + ]); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.tsx index 089ef0cd46a008..f46743778a1683 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.tsx @@ -6,10 +6,9 @@ import React, { useEffect } from 'react'; -import { History } from 'history'; import { useActions, useValues } from 'kea'; import moment from 'moment'; -import { Route, Switch, useHistory, useParams } from 'react-router-dom'; +import { Route, Switch, useParams } from 'react-router-dom'; import { EuiButton, EuiCallOut, EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; @@ -46,14 +45,13 @@ import { SourceInfoCard } from './components/source_info_card'; import { SourceSettings } from './components/source_settings'; export const SourceRouter: React.FC = () => { - const history = useHistory() as History; const { sourceId } = useParams() as { sourceId: string }; const { initializeSource } = useActions(SourceLogic); const { contentSource, dataLoading } = useValues(SourceLogic); const { isOrganization } = useValues(AppLogic); useEffect(() => { - initializeSource(sourceId, history); + initializeSource(sourceId); }, []); if (dataLoading) return ; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts index ab71f764845610..0a3d047796f499 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts @@ -77,6 +77,9 @@ interface ISourcesServerResponse { serviceTypes: Connector[]; } +let pollingInterval: number; +const POLLING_INTERVAL = 10000; + export const SourcesLogic = kea>({ path: ['enterprise_search', 'workplace_search', 'sources_logic'], actions: { @@ -169,6 +172,7 @@ export const SourcesLogic = kea>( try { const response = await HttpLogic.values.http.get(route); + actions.pollForSourceStatusChanges(); actions.onInitializeSources(response); } catch (e) { flashAPIErrors(e); @@ -181,18 +185,20 @@ export const SourcesLogic = kea>( } }, // We poll the server and if the status update, we trigger a new fetch of the sources. - pollForSourceStatusChanges: async () => { + pollForSourceStatusChanges: () => { const { isOrganization } = AppLogic.values; if (!isOrganization) return; const serverStatuses = values.serverStatuses; - const sourceStatuses = await fetchSourceStatuses(isOrganization); + pollingInterval = window.setInterval(async () => { + const sourceStatuses = await fetchSourceStatuses(isOrganization); - sourceStatuses.some((source: ContentSourceStatus) => { - if (serverStatuses && serverStatuses[source.id] !== source.status.status) { - return actions.initializeSources(); - } - }); + sourceStatuses.some((source: ContentSourceStatus) => { + if (serverStatuses && serverStatuses[source.id] !== source.status.status) { + return actions.initializeSources(); + } + }); + }, POLLING_INTERVAL); }, setSourceSearchability: async ({ sourceId, searchable }) => { const { isOrganization } = AppLogic.values; @@ -235,6 +241,14 @@ export const SourcesLogic = kea>( resetFlashMessages: () => { clearFlashMessages(); }, + resetSourcesState: () => { + clearInterval(pollingInterval); + }, + }), + events: () => ({ + beforeUnmount() { + clearInterval(pollingInterval); + }, }), }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.test.tsx new file mode 100644 index 00000000000000..7580203e759a96 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.test.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../__mocks__/shallow_useeffect.mock'; + +import { setMockValues, setMockActions } from '../../../__mocks__'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { Route, Switch, Redirect } from 'react-router-dom'; + +import { ADD_SOURCE_PATH, PERSONAL_SOURCES_PATH, SOURCES_PATH, getSourcesPath } from '../../routes'; + +import { SourcesRouter } from './sources_router'; + +describe('SourcesRouter', () => { + const resetSourcesState = jest.fn(); + const mockValues = { + account: { canCreatePersonalSources: true }, + isOrganization: true, + hasPlatinumLicense: true, + }; + + beforeEach(() => { + setMockActions({ + resetSourcesState, + }); + setMockValues({ ...mockValues }); + }); + + it('renders sources routes', () => { + const TOTAL_ROUTES = 62; + const wrapper = shallow(); + + expect(wrapper.find(Switch)).toHaveLength(1); + expect(wrapper.find(Route)).toHaveLength(TOTAL_ROUTES); + }); + + it('redirects when nonplatinum license and accountOnly context', () => { + setMockValues({ ...mockValues, hasPlatinumLicense: false }); + const wrapper = shallow(); + + expect(wrapper.find(Redirect).first().prop('from')).toEqual(ADD_SOURCE_PATH); + expect(wrapper.find(Redirect).first().prop('to')).toEqual(SOURCES_PATH); + }); + + it('redirects when cannot create sources', () => { + setMockValues({ ...mockValues, account: { canCreatePersonalSources: false } }); + const wrapper = shallow(); + + expect(wrapper.find(Redirect).last().prop('from')).toEqual( + getSourcesPath(ADD_SOURCE_PATH, false) + ); + expect(wrapper.find(Redirect).last().prop('to')).toEqual(PERSONAL_SOURCES_PATH); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_view.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_view.test.tsx new file mode 100644 index 00000000000000..7deb87f4311a5b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_view.test.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../__mocks__/shallow_useeffect.mock'; + +import { setMockValues, setMockActions } from '../../../__mocks__'; + +import { shallow } from 'enzyme'; + +import React from 'react'; + +import { EuiModal } from '@elastic/eui'; + +import { Loading } from '../../../shared/loading'; + +import { SourcesView } from './sources_view'; + +describe('SourcesView', () => { + const resetPermissionsModal = jest.fn(); + const permissionsModal = { + addedSourceName: 'mySource', + serviceType: 'jira', + additionalConfiguration: true, + }; + + const mockValues = { + permissionsModal, + dataLoading: false, + }; + + const children =

test

; + + beforeEach(() => { + setMockActions({ + resetPermissionsModal, + }); + setMockValues({ ...mockValues }); + }); + + it('renders', () => { + const wrapper = shallow({children}); + + expect(wrapper.find('PermissionsModal')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="TestChildren"]')).toHaveLength(1); + }); + + it('returns loading when loading', () => { + setMockValues({ ...mockValues, dataLoading: true }); + const wrapper = shallow({children}); + + expect(wrapper.find(Loading)).toHaveLength(1); + }); + + it('calls function on modal close', () => { + const wrapper = shallow({children}); + const modal = wrapper.find('PermissionsModal').dive().find(EuiModal); + modal.prop('onClose')(); + + expect(resetPermissionsModal).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_view.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_view.tsx index 7e3c14b203e9e1..9e6c8f5b7319ee 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_view.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_view.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect } from 'react'; +import React from 'react'; import { useActions, useValues } from 'kea'; @@ -22,8 +22,6 @@ import { EuiText, } from '@elastic/eui'; -import { clearFlashMessages } from '../../../shared/flash_messages'; - import { Loading } from '../../../shared/loading'; import { SourceIcon } from '../../components/shared/source_icon'; @@ -31,29 +29,14 @@ import { EXTERNAL_IDENTITIES_DOCS_URL, DOCUMENT_PERMISSIONS_DOCS_URL } from '../ import { SourcesLogic } from './sources_logic'; -const POLLING_INTERVAL = 10000; - interface SourcesViewProps { children: React.ReactNode; } export const SourcesView: React.FC = ({ children }) => { - const { initializeSources, pollForSourceStatusChanges, resetPermissionsModal } = useActions( - SourcesLogic - ); - + const { resetPermissionsModal } = useActions(SourcesLogic); const { dataLoading, permissionsModal } = useValues(SourcesLogic); - useEffect(() => { - initializeSources(); - const pollingInterval = window.setInterval(pollForSourceStatusChanges, POLLING_INTERVAL); - - return () => { - clearFlashMessages(); - clearInterval(pollingInterval); - }; - }, []); - if (dataLoading) return ; const PermissionsModal = ({ @@ -113,7 +96,7 @@ export const SourcesView: React.FC = ({ children }) => { return ( <> - {!!permissionsModal && permissionsModal.additionalConfiguration && ( + {permissionsModal?.additionalConfiguration && ( Date: Thu, 21 Jan 2021 17:00:24 -0700 Subject: [PATCH 14/16] [Docs] Add geo threshold and containment docs (#88783) Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> --- docs/user/alerting/alert-types.asciidoc | 2 +- docs/user/alerting/geo-alert-types.asciidoc | 127 ++++++++++++++++++ ...es-tracking-containment-action-options.png | Bin 0 -> 19963 bytes ...-types-tracking-containment-conditions.png | Bin 0 -> 22187 bytes .../images/alert-types-tracking-select.png | Bin 0 -> 37690 bytes ...rt-types-tracking-threshold-conditions.png | Bin 0 -> 37636 bytes docs/user/alerting/index.asciidoc | 1 + 7 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 docs/user/alerting/geo-alert-types.asciidoc create mode 100644 docs/user/alerting/images/alert-types-tracking-containment-action-options.png create mode 100644 docs/user/alerting/images/alert-types-tracking-containment-conditions.png create mode 100644 docs/user/alerting/images/alert-types-tracking-select.png create mode 100644 docs/user/alerting/images/alert-types-tracking-threshold-conditions.png diff --git a/docs/user/alerting/alert-types.asciidoc b/docs/user/alerting/alert-types.asciidoc index 7de5ff56228cc0..7c5a957d1cf794 100644 --- a/docs/user/alerting/alert-types.asciidoc +++ b/docs/user/alerting/alert-types.asciidoc @@ -1,6 +1,6 @@ [role="xpack"] [[alert-types]] -== Alert types +== Standard stack alert types {kib} supplies alert types in two ways: some are built into {kib} (these are known as stack alerts), while domain-specific alert types are registered by {kib} apps such as <>, <>, and <>. diff --git a/docs/user/alerting/geo-alert-types.asciidoc b/docs/user/alerting/geo-alert-types.asciidoc new file mode 100644 index 00000000000000..c04cf4bca4320d --- /dev/null +++ b/docs/user/alerting/geo-alert-types.asciidoc @@ -0,0 +1,127 @@ +[role="xpack"] +[[geo-alert-types]] +== Geo alert types + +experimental[] Two additional stack alerts are available: +<> and <>. To enable, +add the following configuration to your `kibana.yml`: + +```yml +xpack.stack_alerts.enableGeoAlerting: true +``` + +As with other stack alerts, you need `all` access to the *Stack Alerts* feature +to be able to create and edit either of the geo alerts. +See <> for more information on configuring roles that provide access to this feature. + +[float] +=== Geo alert requirements + +To create either a *Tracking threshold* or a *Tracking containment* alert, the +following requirements must be present: + +- *Tracks index or index pattern*: An index containing a `geo_point` field, `date` field, +and some form of entity identifier. An entity identifier is a `keyword` or `number` +field that consistently identifies the entity to be tracked. The data in this index should be dynamically +updating so that there are entity movements to alert upon. +- *Boundaries index or index pattern*: An index containing `geo_shape` data, such as boundary data and bounding box data. +This data is presumed to be static (not updating). Shape data matching the query is +harvested once when the alert is created and anytime after when the alert is re-enabled +after disablement. + +By design, current interval entity locations (_current_ is determined by `date` in +the *Tracked index or index pattern*) are queried to determine if they are contained +within any monitored boundaries. Entity +data should be somewhat "real time", meaning the dates of new documents aren’t older +than the current time minus the amount of the interval. If data older than +`now - ` is ingested, it won't trigger an alert. + +[float] +=== Creating a geo alert +Both *threshold* and *containment* alerts can be created by clicking the *Create* +button in the <>. +Complete the <>. +Select <> to generate an alert when an entity crosses a boundary, and you desire the +ability to highlight lines of crossing on a custom map. +Select +<> if an entity should send out constant alerts +while contained within a boundary (this feature is optional) or if the alert is generally +just more focused around activity when an entity exists within a shape. + +[role="screenshot"] +image::images/alert-types-tracking-select.png[Choosing a tracking alert type] + +[NOTE] +================================================== +With recent advances in the alerting framework, most of the features +available in Tracking threshold alerts can be replicated with just +a little more work in Tracking containment alerts. The capabilities of Tracking +threshold alerts may be deprecated or folded into Tracking containment alerts +in the future. +================================================== + +[float] +[[alert-type-tracking-threshold]] +=== Tracking threshold +The Tracking threshold alert type runs an {es} query over indices, comparing the latest +entity locations with their previous locations. In the event that an entity has crossed a +boundary from the selected boundary index, an alert may be generated. + +[float] +==== Defining the conditions +Tracking threshold has a *Delayed evaluation offset* and 4 clauses that define the +condition to detect, as well as 2 Kuery bars used to provide additional filtering +context for each of the indices. + +[role="screenshot"] +image::images/alert-types-tracking-threshold-conditions.png[Five clauses define the condition to detect] + + +Delayed evaluation offset:: If a data source lags or is intermittent, you may supply +an optional value to evaluate alert conditions following a fixed delay. For instance, if data +is consistently indexed 5-10 minutes following its original timestamp, a *Delayed evaluation +offset* of `10 minutes` would ensure that alertable instances are still captured. +Index (entity):: This clause requires an *index or index pattern*, a *time field* that will be used for the *time window*, and a *`geo_point` field* for tracking. +By:: This clause specifies the field to use in the previously provided +*index or index pattern* for tracking Entities. An entity is a `keyword` +or `number` field that consistently identifies the entity to be tracked. +When entity:: This clause specifies which crossing option to track. The values +*Entered*, *Exited*, and *Crossed* can be selected to indicate which crossing conditions +should trigger an alert. *Entered* alerts on entry into a boundary, *Exited* alerts on exit +from a boundary, and *Crossed* alerts on all boundary crossings whether they be entrances +or exits. +Index (Boundary):: This clause requires an *index or index pattern*, a *`geo_shape` field* +identifying boundaries, and an optional *Human-readable boundary name* for better alerting +messages. + +[float] +[[alert-type-tracking-containment]] +=== Tracking containment +The Tracking containment alert type runs an {es} query over indices, determining if any +documents are currently contained within any boundaries from the specified boundary index. +In the event that an entity is contained within a boundary, an alert may be generated. + +[float] +==== Defining the conditions +Tracking containment alerts have 3 clauses that define the condition to detect, +as well as 2 Kuery bars used to provide additional filtering context for each of the indices. + +[role="screenshot"] +image::images/alert-types-tracking-containment-conditions.png[Five clauses define the condition to detect] + +Index (entity):: This clause requires an *index or index pattern*, a *time field* that will be used for the *time window*, and a *`geo_point` field* for tracking. +When entity:: This clause specifies which crossing option to track. The values +*Entered*, *Exited*, and *Crossed* can be selected to indicate which crossing conditions +should trigger an alert. *Entered* alerts on entry into a boundary, *Exited* alerts on exit +from a boundary, and *Crossed* alerts on all boundary crossings whether they be entrances +or exits. +Index (Boundary):: This clause requires an *index or index pattern*, a *`geo_shape` field* +identifying boundaries, and an optional *Human-readable boundary name* for better alerting +messages. + +Conditions for how an alert is tracked can be specified uniquely for each individual action. +An alert can be triggered either when a containment condition is met or when an entity +is no longer contained. + +[role="screenshot"] +image::images/alert-types-tracking-containment-action-options.png[Five clauses define the condition to detect] diff --git a/docs/user/alerting/images/alert-types-tracking-containment-action-options.png b/docs/user/alerting/images/alert-types-tracking-containment-action-options.png new file mode 100644 index 0000000000000000000000000000000000000000..c0a045f8273829d7a9fbb0d07b19c2a137dbc46f GIT binary patch literal 19963 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 JPDHLkV1n`+-|qkb literal 0 HcmV?d00001 diff --git a/docs/user/alerting/images/alert-types-tracking-containment-conditions.png b/docs/user/alerting/images/alert-types-tracking-containment-conditions.png new file mode 100644 index 0000000000000000000000000000000000000000..32c17d2245d23c9d526e252b802deb75c6984931 GIT binary patch literal 22187 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

Z=b$?CE%Gp~U{yd9HNKQ^@G22Il7oCaCoLa&z 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 Date: Thu, 21 Jan 2021 18:27:27 -0700 Subject: [PATCH 15/16] [Maps] fix setting "apply global time" switch not working with blended vector layer (#88996) * [Maps] fix setting "apply global time" switch not working with blended vector layer * review feedback Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../blended_vector_layer.test.tsx | 46 +++++++++++-------- .../blended_vector_layer.ts | 1 + .../maps/public/classes/layers/layer.tsx | 5 +- .../connected_components/mb_map/mb_map.tsx | 2 +- 4 files changed, 32 insertions(+), 22 deletions(-) diff --git a/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.test.tsx b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.test.tsx index 1321593f015c0e..e029480bd8616b 100644 --- a/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.test.tsx +++ b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.test.tsx @@ -7,7 +7,10 @@ import { SCALING_TYPES, SOURCE_TYPES } from '../../../../common/constants'; import { BlendedVectorLayer } from './blended_vector_layer'; import { ESSearchSource } from '../../sources/es_search_source'; -import { ESGeoGridSourceDescriptor } from '../../../../common/descriptor_types'; +import { + AbstractESSourceDescriptor, + ESGeoGridSourceDescriptor, +} from '../../../../common/descriptor_types'; jest.mock('../../../kibana_services', () => { return { @@ -53,27 +56,12 @@ describe('getSource', () => { expect(source.cloneDescriptor().type).toBe(SOURCE_TYPES.ES_GEO_GRID); }); - test('cluster source applyGlobalQuery should be true when document source applyGlobalQuery is true', async () => { - const blendedVectorLayer = new BlendedVectorLayer({ - source: new ESSearchSource(documentSourceDescriptor), - layerDescriptor: BlendedVectorLayer.createDescriptor( - { - sourceDescriptor: documentSourceDescriptor, - __dataRequests: [clusteredDataRequest], - }, - mapColors - ), - }); - - const source = blendedVectorLayer.getSource(); - expect((source.cloneDescriptor() as ESGeoGridSourceDescriptor).applyGlobalQuery).toBe(true); - }); - - test('cluster source applyGlobalQuery should be false when document source applyGlobalQuery is false', async () => { + test('cluster source AbstractESSourceDescriptor properties should mirror document source AbstractESSourceDescriptor properties', async () => { const blendedVectorLayer = new BlendedVectorLayer({ source: new ESSearchSource({ ...documentSourceDescriptor, applyGlobalQuery: false, + applyGlobalTime: false, }), layerDescriptor: BlendedVectorLayer.createDescriptor( { @@ -85,7 +73,27 @@ describe('getSource', () => { }); const source = blendedVectorLayer.getSource(); - expect((source.cloneDescriptor() as ESGeoGridSourceDescriptor).applyGlobalQuery).toBe(false); + const sourceDescriptor = source.cloneDescriptor() as ESGeoGridSourceDescriptor; + const abstractEsSourceDescriptor: AbstractESSourceDescriptor = { + // Purposely grabbing properties instead of using spread operator + // to ensure type check will fail when new properties are added to AbstractESSourceDescriptor. + // In the event of type check failure, ensure test is updated with new property and that new property + // is correctly passed to clustered source descriptor. + type: sourceDescriptor.type, + id: sourceDescriptor.id, + indexPatternId: sourceDescriptor.indexPatternId, + geoField: sourceDescriptor.geoField, + applyGlobalQuery: sourceDescriptor.applyGlobalQuery, + applyGlobalTime: sourceDescriptor.applyGlobalTime, + }; + expect(abstractEsSourceDescriptor).toEqual({ + type: sourceDescriptor.type, + id: sourceDescriptor.id, + geoField: 'myGeoField', + indexPatternId: 'myIndexPattern', + applyGlobalQuery: false, + applyGlobalTime: false, + } as AbstractESSourceDescriptor); }); }); diff --git a/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts index 825f6ed74777ab..5b33738a91a28f 100644 --- a/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts +++ b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts @@ -62,6 +62,7 @@ function getClusterSource(documentSource: IESSource, documentStyle: IVectorStyle requestType: RENDER_AS.POINT, }); clusterSourceDescriptor.applyGlobalQuery = documentSource.getApplyGlobalQuery(); + clusterSourceDescriptor.applyGlobalTime = documentSource.getApplyGlobalTime(); clusterSourceDescriptor.metrics = [ { type: AGG_TYPE.COUNT, diff --git a/x-pack/plugins/maps/public/classes/layers/layer.tsx b/x-pack/plugins/maps/public/classes/layers/layer.tsx index 060ff4d46fa2ac..fe13e4f0ac2f6c 100644 --- a/x-pack/plugins/maps/public/classes/layers/layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/layer.tsx @@ -5,6 +5,7 @@ */ /* eslint-disable @typescript-eslint/consistent-type-definitions */ +import { Map as MbMap } from 'mapbox-gl'; import { Query } from 'src/plugins/data/public'; import _ from 'lodash'; import React, { ReactElement } from 'react'; @@ -68,7 +69,7 @@ export interface ILayer { ownsMbLayerId(mbLayerId: string): boolean; ownsMbSourceId(mbSourceId: string): boolean; canShowTooltip(): boolean; - syncLayerWithMB(mbMap: unknown): void; + syncLayerWithMB(mbMap: MbMap): void; getLayerTypeIconName(): string; isDataLoaded(): boolean; getIndexPatternIds(): string[]; @@ -418,7 +419,7 @@ export class AbstractLayer implements ILayer { return false; } - syncLayerWithMB(mbMap: unknown) { + syncLayerWithMB(mbMap: MbMap) { throw new Error('Should implement AbstractLayer#syncLayerWithMB'); } diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx b/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx index 4dc765f1704a0b..820453f166a463 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx +++ b/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx @@ -332,7 +332,7 @@ export class MBMap extends Component { this.props.layerList, this.props.spatialFiltersLayer ); - this.props.layerList.forEach((layer) => layer.syncLayerWithMB(this.state.mbMap)); + this.props.layerList.forEach((layer) => layer.syncLayerWithMB(this.state.mbMap!)); syncLayerOrder(this.state.mbMap, this.props.spatialFiltersLayer, this.props.layerList); }; From a0bfdf87fd51cad4973a6d07aca66a90c98b2ed3 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Thu, 21 Jan 2021 19:35:40 -0700 Subject: [PATCH 16/16] [Maps] fix Filter shape stops showing feedback when data refreshes (#89009) * [Maps] fix Filter shape stops showing feedback when data refreshes * update comment * add curly braces around if --- .../mb_map/sort_layers.test.ts | 2 ++ .../connected_components/mb_map/sort_layers.ts | 16 ++++++++++++++++ .../public/connected_components/mb_map/utils.js | 3 ++- 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/sort_layers.test.ts b/x-pack/plugins/maps/public/connected_components/mb_map/sort_layers.test.ts index 9e85c7b04b2662..4e9cb499cf7042 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/sort_layers.test.ts +++ b/x-pack/plugins/maps/public/connected_components/mb_map/sort_layers.test.ts @@ -135,6 +135,7 @@ describe('sortLayer', () => { { id: `${BRAVO_LAYER_ID}_circle`, type: 'circle' } as MbLayer, { id: `${SPATIAL_FILTERS_LAYER_ID}_fill`, type: 'fill' } as MbLayer, { id: `${SPATIAL_FILTERS_LAYER_ID}_circle`, type: 'circle' } as MbLayer, + { id: `gl-draw-polygon-fill-active.cold`, type: 'fill' } as MbLayer, { id: `${CHARLIE_LAYER_ID}_text`, type: 'symbol', @@ -158,6 +159,7 @@ describe('sortLayer', () => { 'alpha_text', 'alpha_circle', 'charlie_text', + 'gl-draw-polygon-fill-active.cold', 'SPATIAL_FILTERS_LAYER_ID_fill', 'SPATIAL_FILTERS_LAYER_ID_circle', ]); diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/sort_layers.ts b/x-pack/plugins/maps/public/connected_components/mb_map/sort_layers.ts index dda43269e32d83..adf68ffb310bca 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/sort_layers.ts +++ b/x-pack/plugins/maps/public/connected_components/mb_map/sort_layers.ts @@ -28,6 +28,10 @@ export function getIsTextLayer(mbLayer: MbLayer) { }); } +export function isGlDrawLayer(mbLayerId: string) { + return mbLayerId.startsWith('gl-draw'); +} + function doesMbLayerBelongToMapLayerAndClass( mapLayer: ILayer, mbLayer: MbLayer, @@ -118,6 +122,18 @@ export function syncLayerOrder(mbMap: MbMap, spatialFiltersLayer: ILayer, layerL } let beneathMbLayerId = getBottomMbLayerId(mbLayers, spatialFiltersLayer, LAYER_CLASS.ANY); + // Ensure gl-draw layers are on top of all layerList layers + const glDrawLayer = ({ + ownsMbLayerId: (mbLayerId: string) => { + return isGlDrawLayer(mbLayerId); + }, + } as unknown) as ILayer; + moveMapLayer(mbMap, mbLayers, glDrawLayer, LAYER_CLASS.ANY, beneathMbLayerId); + const glDrawBottomMbLayerId = getBottomMbLayerId(mbLayers, glDrawLayer, LAYER_CLASS.ANY); + if (glDrawBottomMbLayerId) { + beneathMbLayerId = glDrawBottomMbLayerId; + } + // Sort map layer labels [...layerList] .reverse() diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/utils.js b/x-pack/plugins/maps/public/connected_components/mb_map/utils.js index f12f34061756fa..2f8852174c29eb 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/utils.js +++ b/x-pack/plugins/maps/public/connected_components/mb_map/utils.js @@ -5,6 +5,7 @@ */ import { RGBAImage } from './image_utils'; +import { isGlDrawLayer } from './sort_layers'; export function removeOrphanedSourcesAndLayers(mbMap, layerList, spatialFilterLayer) { const mbStyle = mbMap.getStyle(); @@ -17,7 +18,7 @@ export function removeOrphanedSourcesAndLayers(mbMap, layerList, spatialFilterLa } // ignore gl-draw layers - if (mbLayer.id.startsWith('gl-draw')) { + if (isGlDrawLayer(mbLayer.id)) { return; }