From aa38fb68a7473aec1c341e759a0d9e622d4c346a Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Mon, 6 Jan 2020 11:43:15 +0100 Subject: [PATCH 1/7] Migrate config deprecations and `ShieldUser` functionality to the New Platform (#53768) --- .../framework/kibana_framework_adapter.ts | 8 ++- .../components/pipeline_edit/pipeline_edit.js | 4 +- x-pack/legacy/plugins/security/index.js | 18 +---- .../legacy/plugins/security/public/lib/api.ts | 6 +- .../security/public/services/shield_user.js | 33 --------- .../security/public/views/account/account.js | 26 +++---- .../account_management_page.test.tsx | 71 ++++++++++++++++--- .../components/account_management_page.tsx | 44 +++++++----- .../views/management/edit_role/index.js | 7 +- .../components/edit_user_page.test.tsx | 71 ++++++++++++------- .../edit_user/components/edit_user_page.tsx | 6 +- .../views/management/edit_user/edit_user.js | 9 ++- .../public/views/management/management.js | 15 ++-- .../views/management/users_grid/users.js | 1 - .../overwritten_session.tsx | 52 +++++++------- .../authentication/authentication_service.ts | 31 ++++++++ .../public/authentication/index.mock.ts | 13 ++++ .../security/public/authentication/index.ts | 7 ++ x-pack/plugins/security/public/index.ts | 3 + x-pack/plugins/security/public/mocks.ts | 19 +++++ .../nav_control/nav_control_service.test.ts | 12 +++- .../nav_control/nav_control_service.tsx | 21 +++--- x-pack/plugins/security/public/plugin.ts | 9 ++- .../plugins/security/public/session/index.ts | 2 +- .../public/session/session_timeout.tsx | 2 +- x-pack/plugins/security/server/config.ts | 15 +--- x-pack/plugins/security/server/index.ts | 27 +++++-- x-pack/plugins/security/server/plugin.ts | 7 +- 28 files changed, 326 insertions(+), 213 deletions(-) delete mode 100644 x-pack/legacy/plugins/security/public/services/shield_user.js create mode 100644 x-pack/plugins/security/public/authentication/authentication_service.ts create mode 100644 x-pack/plugins/security/public/authentication/index.mock.ts create mode 100644 x-pack/plugins/security/public/authentication/index.ts create mode 100644 x-pack/plugins/security/public/mocks.ts diff --git a/x-pack/legacy/plugins/beats_management/public/lib/adapters/framework/kibana_framework_adapter.ts b/x-pack/legacy/plugins/beats_management/public/lib/adapters/framework/kibana_framework_adapter.ts index 79ffe58d419bd3..b2cfd826e62079 100644 --- a/x-pack/legacy/plugins/beats_management/public/lib/adapters/framework/kibana_framework_adapter.ts +++ b/x-pack/legacy/plugins/beats_management/public/lib/adapters/framework/kibana_framework_adapter.ts @@ -11,6 +11,8 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom'; import { UIRoutes } from 'ui/routes'; import { isLeft } from 'fp-ts/lib/Either'; +import { npSetup } from 'ui/new_platform'; +import { SecurityPluginSetup } from '../../../../../../../plugins/security/public'; import { BufferedKibanaServiceCall, KibanaAdapterServiceRefs, KibanaUIConfig } from '../../types'; import { FrameworkAdapter, @@ -58,7 +60,7 @@ export class KibanaFrameworkAdapter implements FrameworkAdapter { }; public async waitUntilFrameworkReady(): Promise { - const $injector = await this.onKibanaReady(); + await this.onKibanaReady(); const xpackInfo: any = this.xpackInfoService; let xpackInfoUnpacked: FrameworkInfo; @@ -95,8 +97,10 @@ export class KibanaFrameworkAdapter implements FrameworkAdapter { } this.xpackInfo = xpackInfoUnpacked; + const securitySetup = ((npSetup.plugins as unknown) as { security?: SecurityPluginSetup }) + .security; try { - this.shieldUser = await $injector.get('ShieldUser').getCurrent().$promise; + this.shieldUser = (await securitySetup?.authc.getCurrentUser()) || null; const assertUser = RuntimeFrameworkUser.decode(this.shieldUser); if (isLeft(assertUser)) { diff --git a/x-pack/legacy/plugins/logstash/public/sections/pipeline_edit/components/pipeline_edit/pipeline_edit.js b/x-pack/legacy/plugins/logstash/public/sections/pipeline_edit/components/pipeline_edit/pipeline_edit.js index aa7f88a62397cc..83446278fdeca8 100755 --- a/x-pack/legacy/plugins/logstash/public/sections/pipeline_edit/components/pipeline_edit/pipeline_edit.js +++ b/x-pack/legacy/plugins/logstash/public/sections/pipeline_edit/components/pipeline_edit/pipeline_edit.js @@ -8,6 +8,7 @@ import React from 'react'; import { render } from 'react-dom'; import { isEmpty } from 'lodash'; import { uiModules } from 'ui/modules'; +import { npSetup } from 'ui/new_platform'; import { toastNotifications } from 'ui/notify'; import { I18nContext } from 'ui/i18n'; import { PipelineEditor } from '../../../../components/pipeline_editor'; @@ -21,7 +22,6 @@ app.directive('pipelineEdit', function($injector) { const pipelineService = $injector.get('pipelineService'); const licenseService = $injector.get('logstashLicenseService'); const kbnUrl = $injector.get('kbnUrl'); - const shieldUser = $injector.get('ShieldUser'); const $route = $injector.get('$route'); return { @@ -32,7 +32,7 @@ app.directive('pipelineEdit', function($injector) { scope.$evalAsync(kbnUrl.change(`/management/logstash/pipelines/${id}/edit`)); const userResource = logstashSecurity.isSecurityEnabled() - ? await shieldUser.getCurrent().$promise + ? await npSetup.plugins.security.authc.getCurrentUser() : null; render( diff --git a/x-pack/legacy/plugins/security/index.js b/x-pack/legacy/plugins/security/index.js index 6ee8b5f8b2b10f..bc403b803b8df0 100644 --- a/x-pack/legacy/plugins/security/index.js +++ b/x-pack/legacy/plugins/security/index.js @@ -28,17 +28,10 @@ export const security = kibana => enabled: Joi.boolean().default(true), cookieName: HANDLED_IN_NEW_PLATFORM, encryptionKey: HANDLED_IN_NEW_PLATFORM, - session: Joi.object({ - idleTimeout: HANDLED_IN_NEW_PLATFORM, - lifespan: HANDLED_IN_NEW_PLATFORM, - }).default(), + session: HANDLED_IN_NEW_PLATFORM, secureCookies: HANDLED_IN_NEW_PLATFORM, loginAssistanceMessage: HANDLED_IN_NEW_PLATFORM, - authorization: Joi.object({ - legacyFallback: Joi.object({ - enabled: Joi.boolean().default(true), // deprecated - }).default(), - }).default(), + authorization: HANDLED_IN_NEW_PLATFORM, audit: Joi.object({ enabled: Joi.boolean().default(false), }).default(), @@ -46,13 +39,6 @@ export const security = kibana => }).default(); }, - deprecations: function({ rename, unused }) { - return [ - unused('authorization.legacyFallback.enabled'), - rename('sessionTimeout', 'session.idleTimeout'), - ]; - }, - uiExports: { chromeNavControls: [], managementSections: ['plugins/security/views/management'], diff --git a/x-pack/legacy/plugins/security/public/lib/api.ts b/x-pack/legacy/plugins/security/public/lib/api.ts index ffa08ca44f3765..c5c6994bf4be36 100644 --- a/x-pack/legacy/plugins/security/public/lib/api.ts +++ b/x-pack/legacy/plugins/security/public/lib/api.ts @@ -5,16 +5,12 @@ */ import { kfetch } from 'ui/kfetch'; -import { AuthenticatedUser, Role, User, EditUser } from '../../common/model'; +import { Role, User, EditUser } from '../../common/model'; const usersUrl = '/internal/security/users'; const rolesUrl = '/api/security/role'; export class UserAPIClient { - public async getCurrentUser(): Promise { - return await kfetch({ pathname: `/internal/security/me` }); - } - public async getUsers(): Promise { return await kfetch({ pathname: usersUrl }); } diff --git a/x-pack/legacy/plugins/security/public/services/shield_user.js b/x-pack/legacy/plugins/security/public/services/shield_user.js deleted file mode 100644 index 14a79f267ca752..00000000000000 --- a/x-pack/legacy/plugins/security/public/services/shield_user.js +++ /dev/null @@ -1,33 +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. - */ - -import 'angular-resource'; -import angular from 'angular'; -import { uiModules } from 'ui/modules'; - -const module = uiModules.get('security', ['ngResource']); -module.service('ShieldUser', ($resource, chrome) => { - const baseUrl = chrome.addBasePath('/internal/security/users/:username'); - const ShieldUser = $resource( - baseUrl, - { - username: '@username', - }, - { - changePassword: { - method: 'POST', - url: `${baseUrl}/password`, - transformRequest: ({ password, newPassword }) => angular.toJson({ password, newPassword }), - }, - getCurrent: { - method: 'GET', - url: chrome.addBasePath('/internal/security/me'), - }, - } - ); - - return ShieldUser; -}); diff --git a/x-pack/legacy/plugins/security/public/views/account/account.js b/x-pack/legacy/plugins/security/public/views/account/account.js index db971bd97eab73..70a7b8dce727ec 100644 --- a/x-pack/legacy/plugins/security/public/views/account/account.js +++ b/x-pack/legacy/plugins/security/public/views/account/account.js @@ -6,22 +6,13 @@ import routes from 'ui/routes'; import template from './account.html'; -import '../../services/shield_user'; import { i18n } from '@kbn/i18n'; import { I18nContext } from 'ui/i18n'; +import { npSetup } from 'ui/new_platform'; import { AccountManagementPage } from './components'; import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; -const renderReact = (elem, user) => { - render( - - - , - elem - ); -}; - routes.when('/account', { template, k7Breadcrumbs: () => [ @@ -31,13 +22,8 @@ routes.when('/account', { }), }, ], - resolve: { - user(ShieldUser) { - return ShieldUser.getCurrent().$promise; - }, - }, controllerAs: 'accountController', - controller($scope, $route) { + controller($scope) { $scope.$on('$destroy', () => { const elem = document.getElementById('userProfileReactRoot'); if (elem) { @@ -45,8 +31,12 @@ routes.when('/account', { } }); $scope.$$postDigest(() => { - const elem = document.getElementById('userProfileReactRoot'); - renderReact(elem, $route.current.locals.user); + render( + + + , + document.getElementById('userProfileReactRoot') + ); }); }, }); diff --git a/x-pack/legacy/plugins/security/public/views/account/components/account_management_page.test.tsx b/x-pack/legacy/plugins/security/public/views/account/components/account_management_page.test.tsx index 176b05f455439b..366842e58e9e4a 100644 --- a/x-pack/legacy/plugins/security/public/views/account/components/account_management_page.test.tsx +++ b/x-pack/legacy/plugins/security/public/views/account/components/account_management_page.test.tsx @@ -4,8 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ import React from 'react'; -import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { act } from '@testing-library/react'; +import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; +import { securityMock } from '../../../../../../../plugins/security/public/mocks'; import { AccountManagementPage } from './account_management_page'; +import { AuthenticatedUser } from '../../../../common/model'; jest.mock('ui/kfetch'); @@ -32,10 +35,24 @@ const createUser = ({ withFullName = true, withEmail = true, realm = 'native' }: }; }; +function getSecuritySetupMock({ currentUser }: { currentUser: AuthenticatedUser }) { + const securitySetupMock = securityMock.createSetup(); + securitySetupMock.authc.getCurrentUser.mockResolvedValue(currentUser); + return securitySetupMock; +} + describe('', () => { - it(`displays users full name, username, and email address`, () => { + it(`displays users full name, username, and email address`, async () => { const user = createUser(); - const wrapper = mountWithIntl(); + const wrapper = mountWithIntl( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + expect(wrapper.find('EuiText[data-test-subj="userDisplayName"]').text()).toEqual( user.full_name ); @@ -43,28 +60,60 @@ describe('', () => { expect(wrapper.find('[data-test-subj="email"]').text()).toEqual(user.email); }); - it(`displays username when full_name is not provided`, () => { + it(`displays username when full_name is not provided`, async () => { const user = createUser({ withFullName: false }); - const wrapper = mountWithIntl(); + const wrapper = mountWithIntl( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + expect(wrapper.find('EuiText[data-test-subj="userDisplayName"]').text()).toEqual(user.username); }); - it(`displays a placeholder when no email address is provided`, () => { + it(`displays a placeholder when no email address is provided`, async () => { const user = createUser({ withEmail: false }); - const wrapper = mountWithIntl(); + const wrapper = mountWithIntl( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + expect(wrapper.find('[data-test-subj="email"]').text()).toEqual('no email address'); }); - it(`displays change password form for users in the native realm`, () => { + it(`displays change password form for users in the native realm`, async () => { const user = createUser(); - const wrapper = mountWithIntl(); + const wrapper = mountWithIntl( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + expect(wrapper.find('EuiFieldText[data-test-subj="currentPassword"]')).toHaveLength(1); expect(wrapper.find('EuiFieldText[data-test-subj="newPassword"]')).toHaveLength(1); }); - it(`does not display change password form for users in the saml realm`, () => { + it(`does not display change password form for users in the saml realm`, async () => { const user = createUser({ realm: 'saml' }); - const wrapper = mountWithIntl(); + const wrapper = mountWithIntl( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + expect(wrapper.find('EuiFieldText[data-test-subj="currentPassword"]')).toHaveLength(0); expect(wrapper.find('EuiFieldText[data-test-subj="newPassword"]')).toHaveLength(0); }); diff --git a/x-pack/legacy/plugins/security/public/views/account/components/account_management_page.tsx b/x-pack/legacy/plugins/security/public/views/account/components/account_management_page.tsx index 2ed057ad73a123..6abee73e0b3535 100644 --- a/x-pack/legacy/plugins/security/public/views/account/components/account_management_page.tsx +++ b/x-pack/legacy/plugins/security/public/views/account/components/account_management_page.tsx @@ -4,29 +4,41 @@ * you may not use this file except in compliance with the Elastic License. */ import { EuiPage, EuiPageBody, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; +import { SecurityPluginSetup } from '../../../../../../../plugins/security/public'; import { getUserDisplayName, AuthenticatedUser } from '../../../../common/model'; import { ChangePassword } from './change_password'; import { PersonalInfo } from './personal_info'; interface Props { - user: AuthenticatedUser; + securitySetup: SecurityPluginSetup; } -export const AccountManagementPage: React.FC = props => ( - - - - -

{getUserDisplayName(props.user)}

-
+export const AccountManagementPage = (props: Props) => { + const [currentUser, setCurrentUser] = useState(null); + useEffect(() => { + props.securitySetup.authc.getCurrentUser().then(setCurrentUser); + }, [props]); - + if (!currentUser) { + return null; + } - + return ( + + + + +

{getUserDisplayName(currentUser)}

+
- -
-
-
-); + + + + + +
+
+
+ ); +}; diff --git a/x-pack/legacy/plugins/security/public/views/management/edit_role/index.js b/x-pack/legacy/plugins/security/public/views/management/edit_role/index.js index 09c612526918fe..27c9beb4ba8284 100644 --- a/x-pack/legacy/plugins/security/public/views/management/edit_role/index.js +++ b/x-pack/legacy/plugins/security/public/views/management/edit_role/index.js @@ -11,10 +11,10 @@ import { kfetch } from 'ui/kfetch'; import { fatalError, toastNotifications } from 'ui/notify'; import { npStart } from 'ui/new_platform'; import template from 'plugins/security/views/management/edit_role/edit_role.html'; -import 'plugins/security/services/shield_user'; import 'plugins/security/services/shield_role'; import 'plugins/security/services/shield_indices'; import { xpackInfo } from 'plugins/xpack_main/services/xpack_info'; +import { UserAPIClient } from '../../../lib/api'; import { ROLES_PATH, CLONE_ROLES_PATH, EDIT_ROLES_PATH } from '../management_urls'; import { getEditRoleBreadcrumbs, getCreateRoleBreadcrumbs } from '../breadcrumbs'; @@ -69,9 +69,8 @@ const routeDefinition = action => ({ return role.then(res => res.toJSON()); }, - users(ShieldUser) { - // $promise is used here because the result is an ngResource, not a promise itself - return ShieldUser.query().$promise.then(users => _.map(users, 'username')); + users() { + return new UserAPIClient().getUsers().then(users => _.map(users, 'username')); }, indexPatterns() { return npStart.plugins.data.indexPatterns.getTitles(); diff --git a/x-pack/legacy/plugins/security/public/views/management/edit_user/components/edit_user_page.test.tsx b/x-pack/legacy/plugins/security/public/views/management/edit_user/components/edit_user_page.test.tsx index 5c71d0da3954af..639646ce48e224 100644 --- a/x-pack/legacy/plugins/security/public/views/management/edit_user/components/edit_user_page.test.tsx +++ b/x-pack/legacy/plugins/security/public/views/management/edit_user/components/edit_user_page.test.tsx @@ -4,38 +4,42 @@ * you may not use this file except in compliance with the Elastic License. */ -import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { act } from '@testing-library/react'; +import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; import { EditUserPage } from './edit_user_page'; import React from 'react'; +import { securityMock } from '../../../../../../../../plugins/security/public/mocks'; import { UserAPIClient } from '../../../../lib/api'; import { User, Role } from '../../../../../common/model'; import { ReactWrapper } from 'enzyme'; +import { mockAuthenticatedUser } from '../../../../../../../../plugins/security/common/model/authenticated_user.mock'; jest.mock('ui/kfetch'); -const buildClient = () => { - const apiClient = new UserAPIClient(); +const createUser = (username: string) => { + const user: User = { + username, + full_name: 'my full name', + email: 'foo@bar.com', + roles: ['idk', 'something'], + enabled: true, + }; - const createUser = (username: string) => { - const user: User = { - username, - full_name: 'my full name', - email: 'foo@bar.com', - roles: ['idk', 'something'], - enabled: true, + if (username === 'reserved_user') { + user.metadata = { + _reserved: true, }; + } - if (username === 'reserved_user') { - user.metadata = { - _reserved: true, - }; - } + return user; +}; - return Promise.resolve(user); - }; +const buildClient = () => { + const apiClient = new UserAPIClient(); - apiClient.getUser = jest.fn().mockImplementation(createUser); - apiClient.getCurrentUser = jest.fn().mockImplementation(() => createUser('current_user')); + apiClient.getUser = jest + .fn() + .mockImplementation(async (username: string) => createUser(username)); apiClient.getRoles = jest.fn().mockImplementation(() => { return Promise.resolve([ @@ -63,6 +67,14 @@ const buildClient = () => { return apiClient; }; +function buildSecuritySetup() { + const securitySetupMock = securityMock.createSetup(); + securitySetupMock.authc.getCurrentUser.mockResolvedValue( + mockAuthenticatedUser(createUser('current_user')) + ); + return securitySetupMock; +} + function expectSaveButton(wrapper: ReactWrapper) { expect(wrapper.find('EuiButton[data-test-subj="userFormSaveButton"]')).toHaveLength(1); } @@ -74,10 +86,12 @@ function expectMissingSaveButton(wrapper: ReactWrapper) { describe('EditUserPage', () => { it('allows reserved users to be viewed', async () => { const apiClient = buildClient(); + const securitySetup = buildSecuritySetup(); const wrapper = mountWithIntl( path} intl={null as any} /> @@ -86,17 +100,19 @@ describe('EditUserPage', () => { await waitForRender(wrapper); expect(apiClient.getUser).toBeCalledTimes(1); - expect(apiClient.getCurrentUser).toBeCalledTimes(1); + expect(securitySetup.authc.getCurrentUser).toBeCalledTimes(1); expectMissingSaveButton(wrapper); }); it('allows new users to be created', async () => { const apiClient = buildClient(); + const securitySetup = buildSecuritySetup(); const wrapper = mountWithIntl( path} intl={null as any} /> @@ -105,17 +121,19 @@ describe('EditUserPage', () => { await waitForRender(wrapper); expect(apiClient.getUser).toBeCalledTimes(0); - expect(apiClient.getCurrentUser).toBeCalledTimes(0); + expect(securitySetup.authc.getCurrentUser).toBeCalledTimes(0); expectSaveButton(wrapper); }); it('allows existing users to be edited', async () => { const apiClient = buildClient(); + const securitySetup = buildSecuritySetup(); const wrapper = mountWithIntl( path} intl={null as any} /> @@ -124,16 +142,15 @@ describe('EditUserPage', () => { await waitForRender(wrapper); expect(apiClient.getUser).toBeCalledTimes(1); - expect(apiClient.getCurrentUser).toBeCalledTimes(1); + expect(securitySetup.authc.getCurrentUser).toBeCalledTimes(1); expectSaveButton(wrapper); }); }); async function waitForRender(wrapper: ReactWrapper) { - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - wrapper.update(); + await act(async () => { + await nextTick(); + wrapper.update(); + }); } diff --git a/x-pack/legacy/plugins/security/public/views/management/edit_user/components/edit_user_page.tsx b/x-pack/legacy/plugins/security/public/views/management/edit_user/components/edit_user_page.tsx index 91f5f048adc6d0..bbffe07485f8dc 100644 --- a/x-pack/legacy/plugins/security/public/views/management/edit_user/components/edit_user_page.tsx +++ b/x-pack/legacy/plugins/security/public/views/management/edit_user/components/edit_user_page.tsx @@ -28,6 +28,7 @@ import { } from '@elastic/eui'; import { toastNotifications } from 'ui/notify'; import { FormattedMessage, injectI18n, InjectedIntl } from '@kbn/i18n/react'; +import { SecurityPluginSetup } from '../../../../../../../../plugins/security/public'; import { UserValidator, UserValidationResult } from '../../../../lib/validate_user'; import { User, EditUser, Role } from '../../../../../common/model'; import { USERS_PATH } from '../../../../views/management/management_urls'; @@ -40,6 +41,7 @@ interface Props { intl: InjectedIntl; changeUrl: (path: string) => void; apiClient: UserAPIClient; + securitySetup: SecurityPluginSetup; } interface State { @@ -82,7 +84,7 @@ class EditUserPageUI extends Component { } public async componentDidMount() { - const { username, apiClient } = this.props; + const { username, apiClient, securitySetup } = this.props; let { user, currentUser } = this.state; if (username) { try { @@ -91,7 +93,7 @@ class EditUserPageUI extends Component { password: '', confirmPassword: '', }; - currentUser = await apiClient.getCurrentUser(); + currentUser = await securitySetup.authc.getCurrentUser(); } catch (err) { toastNotifications.addDanger({ title: this.props.intl.formatMessage({ diff --git a/x-pack/legacy/plugins/security/public/views/management/edit_user/edit_user.js b/x-pack/legacy/plugins/security/public/views/management/edit_user/edit_user.js index bd9d6f2b1ca35c..ab218022c6ee64 100644 --- a/x-pack/legacy/plugins/security/public/views/management/edit_user/edit_user.js +++ b/x-pack/legacy/plugins/security/public/views/management/edit_user/edit_user.js @@ -7,7 +7,6 @@ import routes from 'ui/routes'; import template from 'plugins/security/views/management/edit_user/edit_user.html'; import 'angular-resource'; import 'ui/angular_ui_select'; -import 'plugins/security/services/shield_user'; import 'plugins/security/services/shield_role'; import { EDIT_USERS_PATH } from '../management_urls'; import { EditUserPage } from './components'; @@ -15,12 +14,18 @@ import { UserAPIClient } from '../../../lib/api'; import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { I18nContext } from 'ui/i18n'; +import { npSetup } from 'ui/new_platform'; import { getEditUserBreadcrumbs, getCreateUserBreadcrumbs } from '../breadcrumbs'; const renderReact = (elem, changeUrl, username) => { render( - + , elem ); diff --git a/x-pack/legacy/plugins/security/public/views/management/management.js b/x-pack/legacy/plugins/security/public/views/management/management.js index db2175e91c5de9..59da63abbb83ff 100644 --- a/x-pack/legacy/plugins/security/public/views/management/management.js +++ b/x-pack/legacy/plugins/security/public/views/management/management.js @@ -13,10 +13,10 @@ import 'plugins/security/views/management/edit_user/edit_user'; import 'plugins/security/views/management/edit_role/index'; import routes from 'ui/routes'; import { xpackInfo } from 'plugins/xpack_main/services/xpack_info'; -import '../../services/shield_user'; import { ROLES_PATH, USERS_PATH, API_KEYS_PATH } from './management_urls'; import { management } from 'ui/management'; +import { npSetup } from 'ui/new_platform'; import { i18n } from '@kbn/i18n'; import { toastNotifications } from 'ui/notify'; @@ -36,7 +36,7 @@ routes }) .defaults(/\/management/, { resolve: { - securityManagementSection: function(ShieldUser) { + securityManagementSection: function() { const showSecurityLinks = xpackInfo.get('features.security.showLinks'); function deregisterSecurity() { @@ -93,12 +93,11 @@ routes if (!showSecurityLinks) { deregisterSecurity(); } else { - // getCurrent will reject if there is no authenticated user, so we prevent them from seeing the security - // management screens - // - // $promise is used here because the result is an ngResource, not a promise itself - return ShieldUser.getCurrent() - .$promise.then(ensureSecurityRegistered) + // getCurrentUser will reject if there is no authenticated user, so we prevent them from + // seeing the security management screens. + return npSetup.plugins.security.authc + .getCurrentUser() + .then(ensureSecurityRegistered) .catch(deregisterSecurity); } }, diff --git a/x-pack/legacy/plugins/security/public/views/management/users_grid/users.js b/x-pack/legacy/plugins/security/public/views/management/users_grid/users.js index a7115f449ebfd5..8d4e0526251d76 100644 --- a/x-pack/legacy/plugins/security/public/views/management/users_grid/users.js +++ b/x-pack/legacy/plugins/security/public/views/management/users_grid/users.js @@ -8,7 +8,6 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import routes from 'ui/routes'; import template from 'plugins/security/views/management/users_grid/users.html'; -import 'plugins/security/services/shield_user'; import { SECURITY_PATH, USERS_PATH } from '../management_urls'; import { UsersListPage } from './components'; import { UserAPIClient } from '../../../lib/api'; diff --git a/x-pack/legacy/plugins/security/public/views/overwritten_session/overwritten_session.tsx b/x-pack/legacy/plugins/security/public/views/overwritten_session/overwritten_session.tsx index 76088443212b24..fb39c517e1c2ca 100644 --- a/x-pack/legacy/plugins/security/public/views/overwritten_session/overwritten_session.tsx +++ b/x-pack/legacy/plugins/security/public/views/overwritten_session/overwritten_session.tsx @@ -10,36 +10,40 @@ import React from 'react'; import { render } from 'react-dom'; import chrome from 'ui/chrome'; import { I18nContext } from 'ui/i18n'; +import { npSetup } from 'ui/new_platform'; +import { SecurityPluginSetup } from '../../../../../../plugins/security/public'; import { AuthenticatedUser } from '../../../common/model'; import { AuthenticationStatePage } from '../../components/authentication_state_page'; chrome .setVisible(false) .setRootTemplate('
') - .setRootController('overwritten_session', ($scope: any, ShieldUser: any) => { + .setRootController('overwritten_session', ($scope: any) => { $scope.$$postDigest(() => { - ShieldUser.getCurrent().$promise.then((user: AuthenticatedUser) => { - const overwrittenSessionPage = ( - - - } - > - - - - - - ); - render(overwrittenSessionPage, document.getElementById('reactOverwrittenSessionRoot')); - }); + ((npSetup.plugins as unknown) as { security: SecurityPluginSetup }).security.authc + .getCurrentUser() + .then((user: AuthenticatedUser) => { + const overwrittenSessionPage = ( + + + } + > + + + + + + ); + render(overwrittenSessionPage, document.getElementById('reactOverwrittenSessionRoot')); + }); }); }); diff --git a/x-pack/plugins/security/public/authentication/authentication_service.ts b/x-pack/plugins/security/public/authentication/authentication_service.ts new file mode 100644 index 00000000000000..23c45c88e563a1 --- /dev/null +++ b/x-pack/plugins/security/public/authentication/authentication_service.ts @@ -0,0 +1,31 @@ +/* + * 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 { HttpSetup } from 'src/core/public'; +import { AuthenticatedUser } from '../../common/model'; + +interface SetupParams { + http: HttpSetup; +} + +export interface AuthenticationServiceSetup { + /** + * Returns currently authenticated user and throws if current user isn't authenticated. + */ + getCurrentUser: () => Promise; +} + +export class AuthenticationService { + public setup({ http }: SetupParams): AuthenticationServiceSetup { + return { + async getCurrentUser() { + return (await http.get('/internal/security/me', { + headers: { 'kbn-system-api': true }, + })) as AuthenticatedUser; + }, + }; + } +} diff --git a/x-pack/plugins/security/public/authentication/index.mock.ts b/x-pack/plugins/security/public/authentication/index.mock.ts new file mode 100644 index 00000000000000..c8d77a5b62c6f2 --- /dev/null +++ b/x-pack/plugins/security/public/authentication/index.mock.ts @@ -0,0 +1,13 @@ +/* + * 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 { AuthenticationServiceSetup } from './authentication_service'; + +export const authenticationMock = { + createSetup: (): jest.Mocked => ({ + getCurrentUser: jest.fn(), + }), +}; diff --git a/x-pack/plugins/security/public/authentication/index.ts b/x-pack/plugins/security/public/authentication/index.ts new file mode 100644 index 00000000000000..a55f4d7bb95b38 --- /dev/null +++ b/x-pack/plugins/security/public/authentication/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 { AuthenticationService, AuthenticationServiceSetup } from './authentication_service'; diff --git a/x-pack/plugins/security/public/index.ts b/x-pack/plugins/security/public/index.ts index dc34fcbbe7d1e6..336ec37d76a1b9 100644 --- a/x-pack/plugins/security/public/index.ts +++ b/x-pack/plugins/security/public/index.ts @@ -6,7 +6,10 @@ import { PluginInitializer } from 'src/core/public'; import { SecurityPlugin, SecurityPluginSetup, SecurityPluginStart } from './plugin'; + +export { SecurityPluginSetup, SecurityPluginStart }; export { SessionInfo } from './types'; +export { AuthenticatedUser } from '../common/model'; export const plugin: PluginInitializer = () => new SecurityPlugin(); diff --git a/x-pack/plugins/security/public/mocks.ts b/x-pack/plugins/security/public/mocks.ts new file mode 100644 index 00000000000000..3c0c59d10abd1a --- /dev/null +++ b/x-pack/plugins/security/public/mocks.ts @@ -0,0 +1,19 @@ +/* + * 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 { authenticationMock } from './authentication/index.mock'; +import { createSessionTimeoutMock } from './session/session_timeout.mock'; + +function createSetupMock() { + return { + authc: authenticationMock.createSetup(), + sessionTimeout: createSessionTimeoutMock(), + }; +} + +export const securityMock = { + createSetup: createSetupMock, +}; diff --git a/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts b/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts index 3879d611d46ebf..a9a89ee05f561e 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts +++ b/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts @@ -10,6 +10,8 @@ import { ILicense } from '../../../licensing/public'; import { SecurityNavControlService } from '.'; import { SecurityLicenseService } from '../../common/licensing'; import { nextTick } from 'test_utils/enzyme_helpers'; +import { securityMock } from '../mocks'; +import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; const validLicense = { isAvailable: true, @@ -29,13 +31,17 @@ describe('SecurityNavControlService', () => { const license$ = new BehaviorSubject(validLicense); const navControlService = new SecurityNavControlService(); + const mockSecuritySetup = securityMock.createSetup(); + mockSecuritySetup.authc.getCurrentUser.mockResolvedValue( + mockAuthenticatedUser({ username: 'some-user', full_name: undefined }) + ); navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, + authc: mockSecuritySetup.authc, }); const coreStart = coreMock.createStart(); coreStart.chrome.navControls.registerRight = jest.fn(); - coreStart.http.get.mockResolvedValue({ username: 'some-user' }); navControlService.start({ core: coreStart }); expect(coreStart.chrome.navControls.registerRight).toHaveBeenCalledTimes(1); @@ -93,6 +99,7 @@ describe('SecurityNavControlService', () => { const navControlService = new SecurityNavControlService(); navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, + authc: securityMock.createSetup().authc, }); const coreStart = coreMock.createStart(); @@ -111,6 +118,7 @@ describe('SecurityNavControlService', () => { const navControlService = new SecurityNavControlService(); navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, + authc: securityMock.createSetup().authc, }); const coreStart = coreMock.createStart(); @@ -126,6 +134,7 @@ describe('SecurityNavControlService', () => { const navControlService = new SecurityNavControlService(); navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, + authc: securityMock.createSetup().authc, }); const coreStart = coreMock.createStart(); @@ -146,6 +155,7 @@ describe('SecurityNavControlService', () => { const navControlService = new SecurityNavControlService(); navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, + authc: securityMock.createSetup().authc, }); const coreStart = coreMock.createStart(); diff --git a/x-pack/plugins/security/public/nav_control/nav_control_service.tsx b/x-pack/plugins/security/public/nav_control/nav_control_service.tsx index aeeb84219c937d..153e7112dc95b3 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_service.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_service.tsx @@ -9,11 +9,12 @@ import { CoreStart } from 'src/core/public'; import ReactDOM from 'react-dom'; import React from 'react'; import { SecurityLicense } from '../../common/licensing'; -import { AuthenticatedUser } from '../../common/model'; import { SecurityNavControl } from './nav_control_component'; +import { AuthenticationServiceSetup } from '../authentication'; interface SetupDeps { securityLicense: SecurityLicense; + authc: AuthenticationServiceSetup; } interface StartDeps { @@ -22,13 +23,15 @@ interface StartDeps { export class SecurityNavControlService { private securityLicense!: SecurityLicense; + private authc!: AuthenticationServiceSetup; private navControlRegistered!: boolean; private securityFeaturesSubscription?: Subscription; - public setup({ securityLicense }: SetupDeps) { + public setup({ securityLicense, authc }: SetupDeps) { this.securityLicense = securityLicense; + this.authc = authc; } public start({ core }: StartDeps) { @@ -38,14 +41,8 @@ export class SecurityNavControlService { const shouldRegisterNavControl = !isAnonymousPath && showLinks && !this.navControlRegistered; - if (shouldRegisterNavControl) { - const user = core.http.get('/internal/security/me', { - headers: { - 'kbn-system-api': true, - }, - }) as Promise; - this.registerSecurityNavControl(core, user); + this.registerSecurityNavControl(core); } } ); @@ -60,16 +57,16 @@ export class SecurityNavControlService { } private registerSecurityNavControl( - core: Pick, - user: Promise + core: Pick ) { + const currentUserPromise = this.authc.getCurrentUser(); core.chrome.navControls.registerRight({ order: 2000, mount: (el: HTMLElement) => { const I18nContext = core.i18n.Context; const props = { - user, + user: currentUserPromise, editProfileUrl: core.http.basePath.prepend('/app/kibana#/account'), logoutUrl: core.http.basePath.prepend(`/logout`), }; diff --git a/x-pack/plugins/security/public/plugin.ts b/x-pack/plugins/security/public/plugin.ts index 0f10f9d89f25a2..50e0b838c750fc 100644 --- a/x-pack/plugins/security/public/plugin.ts +++ b/x-pack/plugins/security/public/plugin.ts @@ -9,18 +9,20 @@ import { LicensingPluginSetup } from '../../licensing/public'; import { SessionExpired, SessionTimeout, + ISessionTimeout, SessionTimeoutHttpInterceptor, UnauthorizedResponseHttpInterceptor, } from './session'; import { SecurityLicenseService } from '../common/licensing'; import { SecurityNavControlService } from './nav_control'; +import { AuthenticationService } from './authentication'; export interface PluginSetupDependencies { licensing: LicensingPluginSetup; } export class SecurityPlugin implements Plugin { - private sessionTimeout!: SessionTimeout; + private sessionTimeout!: ISessionTimeout; private navControlService!: SecurityNavControlService; @@ -43,12 +45,15 @@ export class SecurityPlugin implements Plugin; private sessionInfo?: SessionInfo; private fetchTimer?: number; diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index b3f96497b0538b..4f1c25702ae974 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -8,7 +8,6 @@ import crypto from 'crypto'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { schema, Type, TypeOf } from '@kbn/config-schema'; -import { duration } from 'moment'; import { PluginInitializerContext } from '../../../../src/core/server'; export type ConfigType = ReturnType extends Observable @@ -35,7 +34,6 @@ export const ConfigSchema = schema.object( schema.maybe(schema.string({ minLength: 32 })), schema.string({ minLength: 32, defaultValue: 'a'.repeat(32) }) ), - sessionTimeout: schema.maybe(schema.nullable(schema.number())), // DEPRECATED session: schema.object({ idleTimeout: schema.nullable(schema.duration()), lifespan: schema.nullable(schema.duration()), @@ -88,22 +86,11 @@ export function createConfig$(context: PluginInitializerContext, isTLSEnabled: b secureCookies = true; } - // "sessionTimeout" is deprecated and replaced with "session.idleTimeout" - // however, NP does not yet have a mechanism to automatically rename deprecated keys - // for the time being, we'll do it manually: - const deprecatedSessionTimeout = - typeof config.sessionTimeout === 'number' ? duration(config.sessionTimeout) : null; - const val = { + return { ...config, encryptionKey, secureCookies, - session: { - ...config.session, - idleTimeout: config.session.idleTimeout || deprecatedSessionTimeout, - }, }; - delete val.sessionTimeout; // DEPRECATED - return val; }) ); } diff --git a/x-pack/plugins/security/server/index.ts b/x-pack/plugins/security/server/index.ts index e189b71345ffc9..33f554be5caa3b 100644 --- a/x-pack/plugins/security/server/index.ts +++ b/x-pack/plugins/security/server/index.ts @@ -4,9 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PluginInitializerContext } from '../../../../src/core/server'; +import { TypeOf } from '@kbn/config-schema'; +import { + PluginConfigDescriptor, + PluginInitializer, + PluginInitializerContext, + RecursiveReadonly, +} from '../../../../src/core/server'; import { ConfigSchema } from './config'; -import { Plugin } from './plugin'; +import { Plugin, PluginSetupContract, PluginSetupDependencies } from './plugin'; // These exports are part of public Security plugin contract, any change in signature of exported // functions or removal of exports should be considered as a breaking change. @@ -17,8 +23,17 @@ export { InvalidateAPIKeyParams, InvalidateAPIKeyResult, } from './authentication'; -export { PluginSetupContract } from './plugin'; +export { PluginSetupContract }; -export const config = { schema: ConfigSchema }; -export const plugin = (initializerContext: PluginInitializerContext) => - new Plugin(initializerContext); +export const config: PluginConfigDescriptor> = { + schema: ConfigSchema, + deprecations: ({ rename, unused }) => [ + rename('sessionTimeout', 'session.idleTimeout'), + unused('authorization.legacyFallback.enabled'), + ], +}; +export const plugin: PluginInitializer< + RecursiveReadonly, + void, + PluginSetupDependencies +> = (initializerContext: PluginInitializerContext) => new Plugin(initializerContext); diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index cdd2a024310bbc..9c4b01f94ef4df 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -110,10 +110,7 @@ export class Plugin { this.logger = this.initializerContext.logger.get(); } - public async setup( - core: CoreSetup, - { features, licensing }: PluginSetupDependencies - ): Promise> { + public async setup(core: CoreSetup, { features, licensing }: PluginSetupDependencies) { const [config, legacyConfig] = await combineLatest([ createConfig$(this.initializerContext, core.http.isTlsEnabled), this.initializerContext.config.legacy.globalConfig$, @@ -169,7 +166,7 @@ export class Plugin { csp: core.http.csp, }); - return deepFreeze({ + return deepFreeze({ authc, authz: { From 5b2a188c4362211798df998e990e2047575ee309 Mon Sep 17 00:00:00 2001 From: Maja Grubic Date: Mon, 6 Jan 2020 10:55:15 +0000 Subject: [PATCH 2/7] [Dashboard] Empty screen redesign (#53681) * Edit screen redesign * Edit screen redesign * Redesign view screen * Redesign view screen * Fixing type failure, and functional test * Updating failing functional tests * update dashboard empty styles * i18n fix * Updating failing snapshot Co-authored-by: Ryan Keairns Co-authored-by: Elastic Machine --- .../dashboard_empty_screen.test.tsx.snap | 512 ++++++++++-------- .../__tests__/dashboard_empty_screen.test.tsx | 5 + .../public/dashboard/_dashboard_app.scss | 28 +- .../np_ready/dashboard_app_controller.tsx | 4 +- .../np_ready/dashboard_empty_screen.tsx | 80 +-- .../dashboard_empty_screen_constants.tsx | 60 +- .../home/assets/welcome_graphic_dark_2x.png | Bin 0 -> 53603 bytes .../home/assets/welcome_graphic_light_2x.png | Bin 0 -> 53122 bytes .../apps/dashboard/empty_dashboard.js | 8 +- .../translations/translations/ja-JP.json | 3 - .../translations/translations/zh-CN.json | 3 - .../feature_controls/dashboard_security.ts | 2 +- .../feature_controls/dashboard_spaces.ts | 2 +- 13 files changed, 402 insertions(+), 305 deletions(-) create mode 100644 src/legacy/core_plugins/kibana/public/home/assets/welcome_graphic_dark_2x.png create mode 100644 src/legacy/core_plugins/kibana/public/home/assets/welcome_graphic_light_2x.png diff --git a/src/legacy/core_plugins/kibana/public/dashboard/__tests__/__snapshots__/dashboard_empty_screen.test.tsx.snap b/src/legacy/core_plugins/kibana/public/dashboard/__tests__/__snapshots__/dashboard_empty_screen.test.tsx.snap index d48e34b2e48370..f611ec978b6b36 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/__tests__/__snapshots__/dashboard_empty_screen.test.tsx.snap +++ b/src/legacy/core_plugins/kibana/public/dashboard/__tests__/__snapshots__/dashboard_empty_screen.test.tsx.snap @@ -2,6 +2,31 @@ exports[`DashboardEmptyScreen renders correctly with visualize paragraph 1`] = ` - -
- -
+ - - -
+ - - - - - - -
- - -
-

- This dashboard is empty. Let’s fill it up! -

-
-
- -
- - -
-

- Click the - - - - button in the menu bar above to add a visualization to the dashboard. -

-
-
- -
- -

- - - -

-
- - -
-
-
-
+ Add an existing + + + +   + + or new object to this dashboard +

+
+ + + + +
+ +

+ + + +

+
@@ -373,6 +361,31 @@ exports[`DashboardEmptyScreen renders correctly with visualize paragraph 1`] = ` exports[`DashboardEmptyScreen renders correctly without visualize paragraph 1`] = `
@@ -581,59 +630,48 @@ exports[`DashboardEmptyScreen renders correctly without visualize paragraph 1`] className="euiPageBody" >
- - - - - - -
- + +
-

This dashboard is empty. Let’s fill it up! -

+

- -
-

- Click the - + - - - button in the menu bar above to start working on your new dashboard. -

-
-
+

+ Click + +   + + + + + +   + + in the menu bar above to start adding panels. +

+
+ +
+ +
diff --git a/src/legacy/core_plugins/kibana/public/dashboard/__tests__/dashboard_empty_screen.test.tsx b/src/legacy/core_plugins/kibana/public/dashboard/__tests__/dashboard_empty_screen.test.tsx index 1c450879ee5530..381ced2efd8e3e 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/__tests__/dashboard_empty_screen.test.tsx +++ b/src/legacy/core_plugins/kibana/public/dashboard/__tests__/dashboard_empty_screen.test.tsx @@ -24,11 +24,16 @@ import { } from '../np_ready/dashboard_empty_screen'; // @ts-ignore import { findTestSubject } from '@elastic/eui/lib/test'; +import { coreMock } from '../../../../../../core/public/mocks'; describe('DashboardEmptyScreen', () => { + const setupMock = coreMock.createSetup(); + const defaultProps = { showLinkToVisualize: true, onLinkClick: jest.fn(), + uiSettings: setupMock.uiSettings, + http: setupMock.http, }; function mountComponent(props?: DashboardEmptyScreenProps) { diff --git a/src/legacy/core_plugins/kibana/public/dashboard/_dashboard_app.scss b/src/legacy/core_plugins/kibana/public/dashboard/_dashboard_app.scss index d9eadf6c0e37d0..03a8a07d6b17da 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/_dashboard_app.scss +++ b/src/legacy/core_plugins/kibana/public/dashboard/_dashboard_app.scss @@ -6,5 +6,31 @@ .dshStartScreen { text-align: center; - padding: $euiSizeS; +} + +.dshStartScreen__pageContent { + padding: $euiSizeXXL; +} + +.dshStartScreen__panelDesc { + max-width: 260px; + margin: 0 auto; +} + +.dshEmptyWidget { + border: $euiBorderThin; + border-style: dashed; + border-radius: $euiBorderRadius; + padding: $euiSizeXXL * 2; + max-width: 400px; + margin-left: $euiSizeS; + text-align: center; +} + +.dshEmptyWidget { + border: 2px dashed $euiColorLightShade; + padding: 4 * $euiSize; + max-width: 20em; + margin-left: 10px; + text-align: center; } diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx index 08637174c8cec9..8fcc7e4c263210 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx @@ -123,7 +123,7 @@ export class DashboardAppController { timefilter: { timefilter }, }, }, - core: { notifications, overlays, chrome, injectedMetadata, uiSettings, savedObjects }, + core: { notifications, overlays, chrome, injectedMetadata, uiSettings, savedObjects, http }, }: DashboardAppControllerDependencies) { new FilterStateManager(globalState, getAppState, filterManager); const queryFilter = filterManager; @@ -197,6 +197,8 @@ export class DashboardAppController { const emptyScreenProps: DashboardEmptyScreenProps = { onLinkClick: shouldShowEditHelp ? $scope.showAddPanel : $scope.enterEditMode, showLinkToVisualize: shouldShowEditHelp, + uiSettings, + http, }; if (shouldShowEditHelp) { emptyScreenProps.onVisualizeClick = addVisualization; diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_empty_screen.tsx b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_empty_screen.tsx index 2fc78d64d0a0cd..ae5319c560ab95 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_empty_screen.tsx +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_empty_screen.tsx @@ -19,94 +19,110 @@ import React from 'react'; import { I18nProvider } from '@kbn/i18n/react'; import { - EuiIcon, EuiLink, EuiSpacer, EuiPageContent, EuiPageBody, EuiPage, + EuiImage, EuiText, EuiButton, } from '@elastic/eui'; +import { IUiSettingsClient, HttpStart } from 'kibana/public'; import * as constants from './dashboard_empty_screen_constants'; export interface DashboardEmptyScreenProps { showLinkToVisualize: boolean; onLinkClick: () => void; onVisualizeClick?: () => void; + uiSettings: IUiSettingsClient; + http: HttpStart; } export function DashboardEmptyScreen({ showLinkToVisualize, onLinkClick, onVisualizeClick, + uiSettings, + http, }: DashboardEmptyScreenProps) { + const IS_DARK_THEME = uiSettings.get('theme:darkMode'); + const emptyStateGraphicURL = IS_DARK_THEME + ? '/plugins/kibana/home/assets/welcome_graphic_dark_2x.png' + : '/plugins/kibana/home/assets/welcome_graphic_light_2x.png'; const linkToVisualizeParagraph = (

{constants.createNewVisualizationButton}

); const paragraph = ( - description1: string, + description1: string | null, description2: string, linkText: string, ariaLabel: string, dataTestSubj?: string ) => { return ( - +

{description1} + {description1 &&  } {linkText} +   {description2}

); }; - const addVisualizationParagraph = ( - - {paragraph( - constants.addVisualizationDescription1, - constants.addVisualizationDescription2, - constants.addVisualizationLinkText, - constants.addVisualizationLinkAriaLabel, - 'emptyDashboardAddPanelButton' - )} - - {linkToVisualizeParagraph} - - ); const enterEditModeParagraph = paragraph( constants.howToStartWorkingOnNewDashboardDescription1, constants.howToStartWorkingOnNewDashboardDescription2, constants.howToStartWorkingOnNewDashboardEditLinkText, constants.howToStartWorkingOnNewDashboardEditLinkAriaLabel ); - return ( - - - - - - - -

{constants.fillDashboardTitle}

-
- - {showLinkToVisualize ? addVisualizationParagraph : enterEditModeParagraph} -
-
-
-
+ const enterViewModeParagraph = paragraph( + null, + constants.addNewVisualizationDescription, + constants.addExistingVisualizationLinkText, + constants.addExistingVisualizationLinkAriaLabel + ); + const viewMode = ( + + + + + +

{constants.fillDashboardTitle}

+
+ +
{enterEditModeParagraph}
+
+
+
+ ); + const editMode = ( +
+ {enterViewModeParagraph} + + {linkToVisualizeParagraph} +
); + return {showLinkToVisualize ? editMode : viewMode}; } diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_empty_screen_constants.tsx b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_empty_screen_constants.tsx index 03004f6270fef9..513e6cb685a7ac 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_empty_screen_constants.tsx +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_empty_screen_constants.tsx @@ -19,40 +19,20 @@ import { i18n } from '@kbn/i18n'; -export const addVisualizationDescription1: string = i18n.translate( - 'kbn.dashboard.addVisualizationDescription1', - { - defaultMessage: 'Click the ', - } -); -export const addVisualizationDescription2: string = i18n.translate( - 'kbn.dashboard.addVisualizationDescription2', - { - defaultMessage: ' button in the menu bar above to add a visualization to the dashboard.', - } -); -export const addVisualizationLinkText: string = i18n.translate( - 'kbn.dashboard.addVisualizationLinkText', - { - defaultMessage: 'Add', - } -); -export const addVisualizationLinkAriaLabel: string = i18n.translate( - 'kbn.dashboard.addVisualizationLinkAriaLabel', - { - defaultMessage: 'Add visualization', - } -); +/** VIEW MODE CONSTANTS **/ +export const fillDashboardTitle: string = i18n.translate('kbn.dashboard.fillDashboardTitle', { + defaultMessage: 'This dashboard is empty. Let\u2019s fill it up!', +}); export const howToStartWorkingOnNewDashboardDescription1: string = i18n.translate( 'kbn.dashboard.howToStartWorkingOnNewDashboardDescription1', { - defaultMessage: 'Click the ', + defaultMessage: 'Click', } ); export const howToStartWorkingOnNewDashboardDescription2: string = i18n.translate( 'kbn.dashboard.howToStartWorkingOnNewDashboardDescription2', { - defaultMessage: ' button in the menu bar above to start working on your new dashboard.', + defaultMessage: 'in the menu bar above to start adding panels.', } ); export const howToStartWorkingOnNewDashboardEditLinkText: string = i18n.translate( @@ -67,13 +47,23 @@ export const howToStartWorkingOnNewDashboardEditLinkAriaLabel: string = i18n.tra defaultMessage: 'Edit dashboard', } ); -export const fillDashboardTitle: string = i18n.translate('kbn.dashboard.fillDashboardTitle', { - defaultMessage: 'This dashboard is empty. Let\u2019s fill it up!', -}); -export const visualizeAppLinkTest: string = i18n.translate( - 'kbn.dashboard.visitVisualizeAppLinkText', +/** EDIT MODE CONSTANTS **/ +export const addExistingVisualizationLinkText: string = i18n.translate( + 'kbn.dashboard.addExistingVisualizationLinkText', + { + defaultMessage: 'Add an existing', + } +); +export const addExistingVisualizationLinkAriaLabel: string = i18n.translate( + 'kbn.dashboard.addVisualizationLinkAriaLabel', + { + defaultMessage: 'Add an existing visualization', + } +); +export const addNewVisualizationDescription: string = i18n.translate( + 'kbn.dashboard.addNewVisualizationText', { - defaultMessage: 'visit the Visualize app', + defaultMessage: 'or new object to this dashboard', } ); export const createNewVisualizationButton: string = i18n.translate( @@ -82,3 +72,9 @@ export const createNewVisualizationButton: string = i18n.translate( defaultMessage: 'Create new', } ); +export const createNewVisualizationButtonAriaLabel: string = i18n.translate( + 'kbn.dashboard.createNewVisualizationButtonAriaLabel', + { + defaultMessage: 'Create new visualization button', + } +); diff --git a/src/legacy/core_plugins/kibana/public/home/assets/welcome_graphic_dark_2x.png b/src/legacy/core_plugins/kibana/public/home/assets/welcome_graphic_dark_2x.png new file mode 100644 index 0000000000000000000000000000000000000000..8f551c54bd5527bcd9f3c6848ec550b4324f474b GIT binary patch literal 53603 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 IBbT_p0jSuq(EtDd literal 0 HcmV?d00001 diff --git a/src/legacy/core_plugins/kibana/public/home/assets/welcome_graphic_light_2x.png b/src/legacy/core_plugins/kibana/public/home/assets/welcome_graphic_light_2x.png new file mode 100644 index 0000000000000000000000000000000000000000..034e8b87ff0f5bdafccb49bbd68d988b342655b6 GIT binary patch literal 53122 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 a#-9q>E7DL(mf6?_JP6m5uySX= { - const addButtonExists = await testSubjects.exists('emptyDashboardAddPanelButton'); - expect(addButtonExists).to.be(true); + it('should display empty widget', async () => { + const emptyWidgetExists = await testSubjects.exists('emptyDashboardWidget'); + expect(emptyWidgetExists).to.be(true); }); it.skip('should open add panel when add button is clicked', async () => { - await testSubjects.click('emptyDashboardAddPanelButton'); + await testSubjects.click('dashboardAddPanelButton'); const isAddPanelOpen = await dashboardAddPanel.isAddPanelOpen(); expect(isAddPanelOpen).to.be(true); }); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index d957e451fdb748..83ef497e50649a 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -1773,10 +1773,7 @@ "kbn.context.reloadPageDescription.selectValidAnchorDocumentTextMessage": "にアクセスして有効な別のドキュメントを選択してください。", "kbn.context.unableToLoadAnchorDocumentDescription": "別のドキュメントが読み込めません", "kbn.context.unableToLoadDocumentDescription": "ドキュメントが読み込めません", - "kbn.dashboard.addVisualizationDescription1": "上のメニューバーの ", - "kbn.dashboard.addVisualizationDescription2": " ボタンをクリックして、ダッシュボードにビジュアライゼーションを追加します。", "kbn.dashboard.addVisualizationLinkAriaLabel": "ビジュアライゼーションを追加", - "kbn.dashboard.addVisualizationLinkText": "追加", "kbn.dashboard.badge.readOnly.text": "読み込み専用", "kbn.dashboard.badge.readOnly.tooltip": "ダッシュボードを保存できません", "kbn.dashboard.changeViewModeConfirmModal.cancelButtonLabel": "編集を続行", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 2e47c7a615e364..87c11adcb5e777 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -1774,10 +1774,7 @@ "kbn.context.reloadPageDescription.selectValidAnchorDocumentTextMessage": "以选择有效地定位点文档。", "kbn.context.unableToLoadAnchorDocumentDescription": "无法加载该定位点文档", "kbn.context.unableToLoadDocumentDescription": "无法加载文档", - "kbn.dashboard.addVisualizationDescription1": "单击上述菜单栏中的 ", - "kbn.dashboard.addVisualizationDescription2": " 按钮,以将可视化添加到仪表板。", "kbn.dashboard.addVisualizationLinkAriaLabel": "添加可视化", - "kbn.dashboard.addVisualizationLinkText": "添加", "kbn.dashboard.badge.readOnly.text": "只读", "kbn.dashboard.badge.readOnly.tooltip": "无法保存仪表板", "kbn.dashboard.changeViewModeConfirmModal.cancelButtonLabel": "继续编辑", diff --git a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts index c7a9764c6fb58e..aa6860b35763f8 100644 --- a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts +++ b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts @@ -107,7 +107,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { shouldLoginIfPrompted: false, } ); - await testSubjects.existOrFail('emptyDashboardAddPanelButton', { timeout: 10000 }); + await testSubjects.existOrFail('emptyDashboardWidget', { timeout: 10000 }); }); it(`can view existing Dashboard`, async () => { diff --git a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_spaces.ts b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_spaces.ts index 127141b156cd80..c1197fa7023c51 100644 --- a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_spaces.ts +++ b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_spaces.ts @@ -73,7 +73,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { shouldLoginIfPrompted: false, } ); - await testSubjects.existOrFail('emptyDashboardAddPanelButton', { timeout: 10000 }); + await testSubjects.existOrFail('emptyDashboardWidget', { timeout: 10000 }); }); it(`can view existing Dashboard`, async () => { From 8992a43c6e4a8d3f1a55b20c520926e817c14a2e Mon Sep 17 00:00:00 2001 From: Gidi Meir Morris Date: Mon, 6 Jan 2020 11:11:18 +0000 Subject: [PATCH 3/7] adds strict types to Alerting Client (#53821) The AlertsClient API currently returns mixed inferred types instead of a clear strict type, making it harder to work with the client's type signatures. The root causes for this difficulty is that we have to support the SavedObjects API which allows partial updates of types, and the implementation of code that converts the SavedObject from a RawAlert to an Alert in a non type-strict manner. To address this we've added concrete types on the AlertsClient APIs, using Partial on update due to the SavedObjects API, and a strict Alert on the other APIs. --- .../plugins/alerting/server/alerts_client.ts | 88 +++++++++++-------- .../alerting/server/routes/create.test.ts | 18 +++- .../alerting/server/routes/get.test.ts | 11 +++ .../legacy/plugins/alerting/server/types.ts | 3 + .../routes/__mocks__/request_responses.ts | 2 - .../lib/detection_engine/rules/find_rules.ts | 9 +- .../lib/detection_engine/rules/types.ts | 5 +- 7 files changed, 90 insertions(+), 46 deletions(-) diff --git a/x-pack/legacy/plugins/alerting/server/alerts_client.ts b/x-pack/legacy/plugins/alerting/server/alerts_client.ts index fc0252c86fe50b..33a6b716e9b8a1 100644 --- a/x-pack/legacy/plugins/alerting/server/alerts_client.ts +++ b/x-pack/legacy/plugins/alerting/server/alerts_client.ts @@ -15,6 +15,7 @@ import { } from 'src/core/server'; import { Alert, + PartialAlert, RawAlert, AlertTypeRegistry, AlertAction, @@ -69,28 +70,26 @@ export interface FindOptions { }; } -interface FindResult { +export interface FindResult { page: number; perPage: number; total: number; - data: object[]; + data: Alert[]; } interface CreateOptions { - data: Pick< + data: Omit< Alert, - Exclude< - keyof Alert, - | 'createdBy' - | 'updatedBy' - | 'createdAt' - | 'updatedAt' - | 'apiKey' - | 'apiKeyOwner' - | 'muteAll' - | 'mutedInstanceIds' - | 'actions' - > + | 'id' + | 'createdBy' + | 'updatedBy' + | 'createdAt' + | 'updatedAt' + | 'apiKey' + | 'apiKeyOwner' + | 'muteAll' + | 'mutedInstanceIds' + | 'actions' > & { actions: NormalizedAlertAction[] }; options?: { migrationVersion?: Record; @@ -146,7 +145,7 @@ export class AlertsClient { this.encryptedSavedObjectsPlugin = encryptedSavedObjectsPlugin; } - public async create({ data, options }: CreateOptions) { + public async create({ data, options }: CreateOptions): Promise { // Throws an error if alert type isn't registered const alertType = this.alertTypeRegistry.get(data.alertTypeId); const validatedAlertTypeParams = validateAlertTypeParams(alertType, data.params); @@ -199,26 +198,29 @@ export class AlertsClient { ); } - public async get({ id }: { id: string }) { + public async get({ id }: { id: string }): Promise { const result = await this.savedObjectsClient.get('alert', id); return this.getAlertFromRaw(result.id, result.attributes, result.updated_at, result.references); } public async find({ options = {} }: FindOptions = {}): Promise { - const results = await this.savedObjectsClient.find({ + const { + page, + per_page: perPage, + total, + saved_objects: data, + } = await this.savedObjectsClient.find({ ...options, type: 'alert', }); - const data = results.saved_objects.map(result => - this.getAlertFromRaw(result.id, result.attributes, result.updated_at, result.references) - ); - return { - page: results.page, - perPage: results.per_page, - total: results.total, - data, + page, + perPage, + total, + data: data.map(({ id, attributes, updated_at, references }) => + this.getAlertFromRaw(id, attributes, updated_at, references) + ), }; } @@ -234,7 +236,7 @@ export class AlertsClient { return removeResult; } - public async update({ id, data }: UpdateOptions) { + public async update({ id, data }: UpdateOptions): Promise { const decryptedAlertSavedObject = await this.encryptedSavedObjectsPlugin.getDecryptedAsInternalUser< RawAlert >('alert', id, { namespace: this.namespace }); @@ -257,7 +259,7 @@ export class AlertsClient { private async updateAlert( { id, data }: UpdateOptions, { attributes, version }: SavedObject - ) { + ): Promise { const alertType = this.alertTypeRegistry.get(attributes.alertTypeId); // Validate @@ -287,7 +289,7 @@ export class AlertsClient { await this.invalidateApiKey({ apiKey: attributes.apiKey }); - return this.getAlertFromRaw( + return this.getPartialAlertFromRaw( id, updatedObject.attributes, updatedObject.updated_at, @@ -494,24 +496,34 @@ export class AlertsClient { } private getAlertFromRaw( + id: string, + rawAlert: RawAlert, + updatedAt: SavedObject['updated_at'], + references: SavedObjectReference[] | undefined + ): Alert { + // In order to support the partial update API of Saved Objects we have to support + // partial updates of an Alert, but when we receive an actual RawAlert, it is safe + // to cast the result to an Alert + return this.getPartialAlertFromRaw(id, rawAlert, updatedAt, references) as Alert; + } + + private getPartialAlertFromRaw( id: string, rawAlert: Partial, updatedAt: SavedObject['updated_at'], references: SavedObjectReference[] | undefined - ) { - if (!rawAlert.actions) { - return { - id, - ...rawAlert, - }; - } - const actions = this.injectReferencesIntoActions(rawAlert.actions, references || []); + ): PartialAlert { return { id, ...rawAlert, + // we currently only support the Interval Schedule type + // Once we support additional types, this type signature will likely change + schedule: rawAlert.schedule as IntervalSchedule, updatedAt: updatedAt ? new Date(updatedAt) : new Date(rawAlert.createdAt!), createdAt: new Date(rawAlert.createdAt!), - actions, + actions: rawAlert.actions + ? this.injectReferencesIntoActions(rawAlert.actions, references || []) + : [], }; } diff --git a/x-pack/legacy/plugins/alerting/server/routes/create.test.ts b/x-pack/legacy/plugins/alerting/server/routes/create.test.ts index 03b33b0bd40b0d..2a0ae78fd78b22 100644 --- a/x-pack/legacy/plugins/alerting/server/routes/create.test.ts +++ b/x-pack/legacy/plugins/alerting/server/routes/create.test.ts @@ -20,6 +20,7 @@ const mockedAlert = { params: { bar: true, }, + throttle: '30s', actions: [ { group: 'default', @@ -44,6 +45,13 @@ test('creates an alert with proper parameters', async () => { const updatedAt = new Date(); alertsClient.create.mockResolvedValueOnce({ ...mockedAlert, + enabled: true, + muteAll: false, + createdBy: '', + updatedBy: '', + apiKey: '', + apiKeyOwner: '', + mutedInstanceIds: [], createdAt, updatedAt, id: '123', @@ -71,8 +79,14 @@ test('creates an alert with proper parameters', async () => { }, ], "alertTypeId": "1", + "apiKey": "", + "apiKeyOwner": "", "consumer": "bar", + "createdBy": "", + "enabled": true, "id": "123", + "muteAll": false, + "mutedInstanceIds": Array [], "name": "abc", "params": Object { "bar": true, @@ -83,6 +97,8 @@ test('creates an alert with proper parameters', async () => { "tags": Array [ "foo", ], + "throttle": "30s", + "updatedBy": "", } `); expect(alertsClient.create).toHaveBeenCalledTimes(1); @@ -112,7 +128,7 @@ test('creates an alert with proper parameters', async () => { "tags": Array [ "foo", ], - "throttle": null, + "throttle": "30s", }, }, ] diff --git a/x-pack/legacy/plugins/alerting/server/routes/get.test.ts b/x-pack/legacy/plugins/alerting/server/routes/get.test.ts index 5b1bdc7f697086..320e9042d87c59 100644 --- a/x-pack/legacy/plugins/alerting/server/routes/get.test.ts +++ b/x-pack/legacy/plugins/alerting/server/routes/get.test.ts @@ -29,6 +29,17 @@ const mockedAlert = { }, }, ], + consumer: 'bar', + name: 'abc', + tags: ['foo'], + enabled: true, + muteAll: false, + createdBy: '', + updatedBy: '', + apiKey: '', + apiKeyOwner: '', + throttle: '30s', + mutedInstanceIds: [], }; beforeEach(() => jest.resetAllMocks()); diff --git a/x-pack/legacy/plugins/alerting/server/types.ts b/x-pack/legacy/plugins/alerting/server/types.ts index a2390bf93d005a..62dcf07abb7bdd 100644 --- a/x-pack/legacy/plugins/alerting/server/types.ts +++ b/x-pack/legacy/plugins/alerting/server/types.ts @@ -65,6 +65,7 @@ export interface IntervalSchedule extends SavedObjectAttributes { } export interface Alert { + id: string; enabled: boolean; name: string; tags: string[]; @@ -85,6 +86,8 @@ export interface Alert { mutedInstanceIds: string[]; } +export type PartialAlert = Pick & Partial>; + export interface RawAlert extends SavedObjectAttributes { enabled: boolean; name: string; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts index 2e16f209acfb17..edf196b96f5d02 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -283,9 +283,7 @@ export const getResult = (): RuleAlertType => ({ ], riskScore: 50, maxSignals: 100, - size: 1, severity: 'high', - tags: [], to: 'now', type: 'query', threats: [ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/find_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/find_rules.ts index c1058bd353e8ce..5f69082e3fc719 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/find_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/find_rules.ts @@ -5,7 +5,7 @@ */ import { SIGNALS_ID } from '../../../../common/constants'; -import { FindRuleParams } from './types'; +import { FindRuleParams, RuleAlertType } from './types'; export const getFilter = (filter: string | null | undefined) => { if (filter == null) { @@ -33,5 +33,10 @@ export const findRules = async ({ sortOrder, sortField, }, - }); + }) as Promise<{ + page: number; + perPage: number; + total: number; + data: RuleAlertType[]; + }>; }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/types.ts index b0578174e1f658..4f4c0da7127cd7 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/types.ts @@ -35,10 +35,9 @@ export interface BulkUpdateRulesRequest extends RequestFacade { payload: UpdateRuleAlertParamsRest[]; } -export type RuleAlertType = Alert & { - id: string; +export interface RuleAlertType extends Alert { params: RuleTypeParams; -}; +} export interface RulesRequest extends RequestFacade { payload: RuleAlertParamsRest; From fd4bb8e8a4ff279421aa58b5ea076fc48f55e21d Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Mon, 6 Jan 2020 15:26:51 +0300 Subject: [PATCH 4/7] use NP deprecations in uiSettings (#53755) * use NP deprecation iunstead of manual one in uiSettings * add ServiceConfigDescriptor type Co-authored-by: Elastic Machine --- .../config/deprecation/core_deprecations.ts | 1 - src/core/server/http/http_config.ts | 11 ------ src/core/server/http/http_service.mock.ts | 1 - src/core/server/http/http_service.ts | 4 --- src/core/server/http/types.ts | 10 ------ src/core/server/internal_types.ts | 18 ++++++++++ ...gacy_object_to_config_adapter.test.ts.snap | 2 -- .../config/legacy_object_to_config_adapter.ts | 1 - src/core/server/server.ts | 4 +++ .../server/ui_settings/ui_settings_config.ts | 36 ++++++++++++++----- .../ui_settings/ui_settings_service.test.ts | 22 ------------ .../server/ui_settings/ui_settings_service.ts | 18 ++-------- src/legacy/server/config/schema.js | 1 - .../http/setup_default_route_provider.ts | 2 +- 14 files changed, 54 insertions(+), 77 deletions(-) diff --git a/src/core/server/config/deprecation/core_deprecations.ts b/src/core/server/config/deprecation/core_deprecations.ts index 6a401ec6625a20..36fe95e05cb531 100644 --- a/src/core/server/config/deprecation/core_deprecations.ts +++ b/src/core/server/config/deprecation/core_deprecations.ts @@ -97,7 +97,6 @@ export const coreDeprecationProvider: ConfigDeprecationProvider = ({ }) => [ unusedFromRoot('savedObjects.indexCheckTimeout'), unusedFromRoot('server.xsrf.token'), - unusedFromRoot('uiSettings.enabled'), renameFromRoot('optimize.lazy', 'optimize.watch'), renameFromRoot('optimize.lazyPort', 'optimize.watchPort'), renameFromRoot('optimize.lazyHost', 'optimize.watchHost'), diff --git a/src/core/server/http/http_config.ts b/src/core/server/http/http_config.ts index 5749eb383f8b95..92a8d6a95b854f 100644 --- a/src/core/server/http/http_config.ts +++ b/src/core/server/http/http_config.ts @@ -39,15 +39,6 @@ export const config = { validate: match(validBasePathRegex, "must start with a slash, don't end with one"), }) ), - defaultRoute: schema.maybe( - schema.string({ - validate(value) { - if (!value.startsWith('/')) { - return 'must start with a slash'; - } - }, - }) - ), cors: schema.conditional( schema.contextRef('dev'), true, @@ -134,7 +125,6 @@ export class HttpConfig { public maxPayload: ByteSizeValue; public basePath?: string; public rewriteBasePath: boolean; - public defaultRoute?: string; public ssl: SslConfig; public compression: { enabled: boolean; referrerWhitelist?: string[] }; public csp: ICspConfig; @@ -153,7 +143,6 @@ export class HttpConfig { this.socketTimeout = rawHttpConfig.socketTimeout; this.rewriteBasePath = rawHttpConfig.rewriteBasePath; this.ssl = new SslConfig(rawHttpConfig.ssl || {}); - this.defaultRoute = rawHttpConfig.defaultRoute; this.compression = rawHttpConfig.compression; this.csp = new CspConfig(rawCspConfig); } diff --git a/src/core/server/http/http_service.mock.ts b/src/core/server/http/http_service.mock.ts index 700ae04f00d47a..6db1ca80ab4370 100644 --- a/src/core/server/http/http_service.mock.ts +++ b/src/core/server/http/http_service.mock.ts @@ -68,7 +68,6 @@ const createSetupContractMock = () => { getAuthHeaders: jest.fn(), }, isTlsEnabled: false, - config: {}, }; setupContract.createCookieSessionStorageFactory.mockResolvedValue( sessionStorageMock.createFactory() diff --git a/src/core/server/http/http_service.ts b/src/core/server/http/http_service.ts index e038443d5c83f1..09982cf164a19a 100644 --- a/src/core/server/http/http_service.ts +++ b/src/core/server/http/http_service.ts @@ -107,10 +107,6 @@ export class HttpService implements CoreService ) => this.requestHandlerContext!.registerContext(pluginOpaqueId, contextName, provider), - - config: { - defaultRoute: config.defaultRoute, - }, }; return contract; diff --git a/src/core/server/http/types.ts b/src/core/server/http/types.ts index 92217515a22a1c..9c8bfc073a5248 100644 --- a/src/core/server/http/types.ts +++ b/src/core/server/http/types.ts @@ -250,16 +250,6 @@ export interface InternalHttpServiceSetup contextName: T, provider: RequestHandlerContextProvider ) => RequestHandlerContextContainer; - config: { - /** - * @internalRemarks - * Deprecated part of the server config, provided until - * https://github.com/elastic/kibana/issues/40255 - * - * @deprecated - * */ - defaultRoute?: string; - }; } /** @public */ diff --git a/src/core/server/internal_types.ts b/src/core/server/internal_types.ts index be4d830c55eab7..ff68d1544d119e 100644 --- a/src/core/server/internal_types.ts +++ b/src/core/server/internal_types.ts @@ -17,7 +17,10 @@ * under the License. */ +import { Type } from '@kbn/config-schema'; + import { CapabilitiesSetup, CapabilitiesStart } from './capabilities'; +import { ConfigDeprecationProvider } from './config'; import { ContextSetup } from './context'; import { InternalElasticsearchServiceSetup } from './elasticsearch'; import { InternalHttpServiceSetup } from './http'; @@ -47,3 +50,18 @@ export interface InternalCoreStart { savedObjects: InternalSavedObjectsServiceStart; uiSettings: InternalUiSettingsServiceStart; } + +/** + * @internal + */ +export interface ServiceConfigDescriptor { + path: string; + /** + * Schema to use to validate the configuration. + */ + schema: Type; + /** + * Provider for the {@link ConfigDeprecation} to apply to the plugin configuration. + */ + deprecations?: ConfigDeprecationProvider; +} diff --git a/src/core/server/legacy/config/__snapshots__/legacy_object_to_config_adapter.test.ts.snap b/src/core/server/legacy/config/__snapshots__/legacy_object_to_config_adapter.test.ts.snap index 0ebd8b83716280..3161dd06cf3b65 100644 --- a/src/core/server/legacy/config/__snapshots__/legacy_object_to_config_adapter.test.ts.snap +++ b/src/core/server/legacy/config/__snapshots__/legacy_object_to_config_adapter.test.ts.snap @@ -8,7 +8,6 @@ Object { "enabled": true, }, "cors": false, - "defaultRoute": undefined, "host": "host", "keepaliveTimeout": 5000, "maxPayload": 1000, @@ -32,7 +31,6 @@ Object { "enabled": true, }, "cors": false, - "defaultRoute": undefined, "host": "host", "keepaliveTimeout": 5000, "maxPayload": 1000, diff --git a/src/core/server/legacy/config/legacy_object_to_config_adapter.ts b/src/core/server/legacy/config/legacy_object_to_config_adapter.ts index bdcde8262ef981..458c1f1f119ee4 100644 --- a/src/core/server/legacy/config/legacy_object_to_config_adapter.ts +++ b/src/core/server/legacy/config/legacy_object_to_config_adapter.ts @@ -64,7 +64,6 @@ export class LegacyObjectToConfigAdapter extends ObjectToConfigAdapter { return { autoListen: configValue.autoListen, basePath: configValue.basePath, - defaultRoute: configValue.defaultRoute, cors: configValue.cors, host: configValue.host, maxPayload: configValue.maxPayloadBytes, diff --git a/src/core/server/server.ts b/src/core/server/server.ts index 6ae9fd07f3d5fb..4239e7e6c42540 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -257,6 +257,10 @@ export class Server { ]; this.configService.addDeprecationProvider(rootConfigPath, coreDeprecationProvider); + this.configService.addDeprecationProvider( + uiSettingsConfig.path, + uiSettingsConfig.deprecations! + ); for (const [path, schema] of schemas) { await this.configService.setSchema(path, schema); diff --git a/src/core/server/ui_settings/ui_settings_config.ts b/src/core/server/ui_settings/ui_settings_config.ts index 702286f953ef12..a54d482a0296a4 100644 --- a/src/core/server/ui_settings/ui_settings_config.ts +++ b/src/core/server/ui_settings/ui_settings_config.ts @@ -18,15 +18,35 @@ */ import { schema, TypeOf } from '@kbn/config-schema'; +import { ConfigDeprecationProvider } from 'src/core/server'; +import { ServiceConfigDescriptor } from '../internal_types'; -export type UiSettingsConfigType = TypeOf; +const deprecations: ConfigDeprecationProvider = ({ unused, renameFromRoot }) => [ + unused('enabled'), + renameFromRoot('server.defaultRoute', 'uiSettings.overrides.defaultRoute'), +]; -export const config = { +const configSchema = schema.object({ + overrides: schema.object( + { + defaultRoute: schema.maybe( + schema.string({ + validate(value) { + if (!value.startsWith('/')) { + return 'must start with a slash'; + } + }, + }) + ), + }, + { allowUnknowns: true } + ), +}); + +export type UiSettingsConfigType = TypeOf; + +export const config: ServiceConfigDescriptor = { path: 'uiSettings', - schema: schema.object({ - overrides: schema.object({}, { allowUnknowns: true }), - // Deprecation is implemented in LP. - // We define schema here not to fail on the validation step. - enabled: schema.maybe(schema.boolean()), - }), + schema: configSchema, + deprecations, }; diff --git a/src/core/server/ui_settings/ui_settings_service.test.ts b/src/core/server/ui_settings/ui_settings_service.test.ts index d908a91a39c707..9b6a5ba0a48841 100644 --- a/src/core/server/ui_settings/ui_settings_service.test.ts +++ b/src/core/server/ui_settings/ui_settings_service.test.ts @@ -22,7 +22,6 @@ import { MockUiSettingsClientConstructor } from './ui_settings_service.test.mock import { UiSettingsService } from './ui_settings_service'; import { httpServiceMock } from '../http/http_service.mock'; -import { loggingServiceMock } from '../logging/logging_service.mock'; import { savedObjectsClientMock } from '../mocks'; import { mockCoreContext } from '../core_context.mock'; @@ -69,27 +68,6 @@ describe('uiSettings', () => { expect(MockUiSettingsClientConstructor.mock.calls[0][0].overrides).toEqual(overrides); }); - it('passes overrides with deprecated "server.defaultRoute"', async () => { - const service = new UiSettingsService(coreContext); - const httpSetupWithDefaultRoute = httpServiceMock.createSetupContract(); - httpSetupWithDefaultRoute.config.defaultRoute = '/defaultRoute'; - const setup = await service.setup({ http: httpSetupWithDefaultRoute }); - setup.asScopedToClient(savedObjectsClient); - - expect(MockUiSettingsClientConstructor.mock.calls[0][0].overrides).toEqual({ - ...overrides, - defaultRoute: '/defaultRoute', - }); - - expect(loggingServiceMock.collect(coreContext.logger).warn).toMatchInlineSnapshot(` - Array [ - Array [ - "Config key \\"server.defaultRoute\\" is deprecated. It has been replaced with \\"uiSettings.overrides.defaultRoute\\"", - ], - ] - `); - }); - it('passes a copy of set defaults to UiSettingsClient', async () => { const service = new UiSettingsService(coreContext); const setup = await service.setup(setupDeps); diff --git a/src/core/server/ui_settings/ui_settings_service.ts b/src/core/server/ui_settings/ui_settings_service.ts index db08c3cad85a2d..942c2625ac8e7c 100644 --- a/src/core/server/ui_settings/ui_settings_service.ts +++ b/src/core/server/ui_settings/ui_settings_service.ts @@ -56,7 +56,9 @@ export class UiSettingsService public async setup(deps: SetupDeps): Promise { registerRoutes(deps.http.createRouter('')); this.log.debug('Setting up ui settings service'); - this.overrides = await this.getOverrides(deps); + const config = await this.config$.pipe(first()).toPromise(); + this.overrides = config.overrides; + return { register: this.register.bind(this), asScopedToClient: this.getScopedClientFactory(), @@ -95,18 +97,4 @@ export class UiSettingsService this.uiSettingsDefaults.set(key, value); }); } - - private async getOverrides(deps: SetupDeps) { - const config = await this.config$.pipe(first()).toPromise(); - const overrides: Record = config.overrides; - // manually implemented deprecation until New platform Config service - // supports them https://github.com/elastic/kibana/issues/40255 - if (typeof deps.http.config.defaultRoute !== 'undefined') { - overrides.defaultRoute = deps.http.config.defaultRoute; - this.log.warn( - 'Config key "server.defaultRoute" is deprecated. It has been replaced with "uiSettings.overrides.defaultRoute"' - ); - } - return overrides; - } } diff --git a/src/legacy/server/config/schema.js b/src/legacy/server/config/schema.js index f886fd598f5c96..183904ff35985e 100644 --- a/src/legacy/server/config/schema.js +++ b/src/legacy/server/config/schema.js @@ -70,7 +70,6 @@ export default () => server: Joi.object({ name: Joi.string().default(os.hostname()), - defaultRoute: Joi.string().regex(/^\//, `start with a slash`), customResponseHeaders: Joi.object() .unknown(true) .default({}), diff --git a/src/legacy/server/http/setup_default_route_provider.ts b/src/legacy/server/http/setup_default_route_provider.ts index 0e7bcf1f56f6fc..9a580dd1c59bdc 100644 --- a/src/legacy/server/http/setup_default_route_provider.ts +++ b/src/legacy/server/http/setup_default_route_provider.ts @@ -29,7 +29,7 @@ export function setupDefaultRouteProvider(server: Legacy.Server) { const uiSettings = request.getUiSettingsService(); - const defaultRoute = await uiSettings.get('defaultRoute'); + const defaultRoute = await uiSettings.get('defaultRoute'); const qualifiedDefaultRoute = `${request.getBasePath()}${defaultRoute}`; if (isRelativePath(qualifiedDefaultRoute, serverBasePath)) { From 205fbce657c3af3834d5ad33586d04e17c906298 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Mon, 6 Jan 2020 15:21:21 +0100 Subject: [PATCH 5/7] migrate xsrf / version-check / custom-headers handlers to NP (#53684) * migrate xsrf / version-check / custom-headers handlers to NP * export lifecycleMock to be used by plugins * move toolkit mock to http_server mock * remove legacy config tests on xsrf * fix integration_test http configuration * remove direct HAPI usages from integration tests * nits and comments * add custom headers test in case of server returning error * resolve merge conflicts * restore `server.name` to legacy config --- .../__snapshots__/http_config.test.ts.snap | 6 + .../http/cookie_session_storage.test.ts | 4 + src/core/server/http/http_config.test.ts | 23 ++ src/core/server/http/http_config.ts | 19 ++ src/core/server/http/http_server.mocks.ts | 13 + src/core/server/http/http_server.ts | 6 + .../server/http/http_service.test.mocks.ts | 4 + src/core/server/http/http_service.ts | 19 +- .../lifecycle_handlers.test.ts | 241 ++++++++++++++++ .../server/http/lifecycle_handlers.test.ts | 269 ++++++++++++++++++ src/core/server/http/lifecycle_handlers.ts | 93 ++++++ src/core/server/http/test_utils.ts | 4 + ...gacy_object_to_config_adapter.test.ts.snap | 16 ++ .../legacy_object_to_config_adapter.test.ts | 12 + .../config/legacy_object_to_config_adapter.ts | 5 +- src/legacy/server/config/schema.js | 15 +- src/legacy/server/config/schema.test.js | 56 ---- src/legacy/server/http/index.js | 28 -- .../integration_tests/version_check.test.js | 64 ----- .../http/integration_tests/xsrf.test.js | 145 ---------- src/legacy/server/http/version_check.js | 39 --- src/legacy/server/http/xsrf.js | 47 --- src/test_utils/kbn_server.ts | 4 +- 23 files changed, 730 insertions(+), 402 deletions(-) create mode 100644 src/core/server/http/integration_tests/lifecycle_handlers.test.ts create mode 100644 src/core/server/http/lifecycle_handlers.test.ts create mode 100644 src/core/server/http/lifecycle_handlers.ts delete mode 100644 src/legacy/server/http/integration_tests/version_check.test.js delete mode 100644 src/legacy/server/http/integration_tests/xsrf.test.js delete mode 100644 src/legacy/server/http/version_check.js delete mode 100644 src/legacy/server/http/xsrf.js diff --git a/src/core/server/http/__snapshots__/http_config.test.ts.snap b/src/core/server/http/__snapshots__/http_config.test.ts.snap index 6c690f9da70c3f..8856eb95ba722b 100644 --- a/src/core/server/http/__snapshots__/http_config.test.ts.snap +++ b/src/core/server/http/__snapshots__/http_config.test.ts.snap @@ -31,11 +31,13 @@ Object { "enabled": true, }, "cors": false, + "customResponseHeaders": Object {}, "host": "localhost", "keepaliveTimeout": 120000, "maxPayload": ByteSizeValue { "valueInBytes": 1048576, }, + "name": "kibana-hostname", "port": 5601, "rewriteBasePath": false, "socketTimeout": 120000, @@ -70,6 +72,10 @@ Object { "TLSv1.2", ], }, + "xsrf": Object { + "disableProtection": false, + "whitelist": Array [], + }, } `; diff --git a/src/core/server/http/cookie_session_storage.test.ts b/src/core/server/http/cookie_session_storage.test.ts index 0e4f3972fe9dc6..4ce422e1f65c44 100644 --- a/src/core/server/http/cookie_session_storage.test.ts +++ b/src/core/server/http/cookie_session_storage.test.ts @@ -58,6 +58,10 @@ configService.atPath.mockReturnValue( verificationMode: 'none', }, compression: { enabled: true }, + xsrf: { + disableProtection: true, + whitelist: [], + }, } as any) ); diff --git a/src/core/server/http/http_config.test.ts b/src/core/server/http/http_config.test.ts index 082b85ad68add1..3dc5fa48bc3665 100644 --- a/src/core/server/http/http_config.test.ts +++ b/src/core/server/http/http_config.test.ts @@ -23,6 +23,11 @@ import { config, HttpConfig } from '.'; const validHostnames = ['www.example.com', '8.8.8.8', '::1', 'localhost']; const invalidHostname = 'asdf$%^'; +jest.mock('os', () => ({ + ...jest.requireActual('os'), + hostname: () => 'kibana-hostname', +})); + test('has defaults for config', () => { const httpSchema = config.schema; const obj = {}; @@ -84,6 +89,24 @@ test('accepts only valid uuids for server.uuid', () => { ); }); +test('uses os.hostname() as default for server.name', () => { + const httpSchema = config.schema; + const validated = httpSchema.validate({}); + expect(validated.name).toEqual('kibana-hostname'); +}); + +test('throws if xsrf.whitelist element does not start with a slash', () => { + const httpSchema = config.schema; + const obj = { + xsrf: { + whitelist: ['/valid-path', 'invalid-path'], + }, + }; + expect(() => httpSchema.validate(obj)).toThrowErrorMatchingInlineSnapshot( + `"[xsrf.whitelist.1]: must start with a slash"` + ); +}); + describe('with TLS', () => { test('throws if TLS is enabled but `key` is not specified', () => { const httpSchema = config.schema; diff --git a/src/core/server/http/http_config.ts b/src/core/server/http/http_config.ts index 92a8d6a95b854f..73f44f3c5ab5ce 100644 --- a/src/core/server/http/http_config.ts +++ b/src/core/server/http/http_config.ts @@ -18,6 +18,8 @@ */ import { ByteSizeValue, schema, TypeOf } from '@kbn/config-schema'; +import { hostname } from 'os'; + import { CspConfigType, CspConfig, ICspConfig } from '../csp'; import { SslConfig, sslSchema } from './ssl_config'; @@ -33,6 +35,7 @@ export const config = { path: 'server', schema: schema.object( { + name: schema.string({ defaultValue: () => hostname() }), autoListen: schema.boolean({ defaultValue: true }), basePath: schema.maybe( schema.string({ @@ -54,6 +57,9 @@ export const config = { ), schema.boolean({ defaultValue: false }) ), + customResponseHeaders: schema.recordOf(schema.string(), schema.string(), { + defaultValue: {}, + }), host: schema.string({ defaultValue: 'localhost', hostname: true, @@ -88,6 +94,13 @@ export const config = { validate: match(uuidRegexp, 'must be a valid uuid'), }) ), + xsrf: schema.object({ + disableProtection: schema.boolean({ defaultValue: false }), + whitelist: schema.arrayOf( + schema.string({ validate: match(/^\//, 'must start with a slash') }), + { defaultValue: [] } + ), + }), }, { validate: rawConfig => { @@ -116,18 +129,21 @@ export const config = { export type HttpConfigType = TypeOf; export class HttpConfig { + public name: string; public autoListen: boolean; public host: string; public keepaliveTimeout: number; public socketTimeout: number; public port: number; public cors: boolean | { origin: string[] }; + public customResponseHeaders: Record; public maxPayload: ByteSizeValue; public basePath?: string; public rewriteBasePath: boolean; public ssl: SslConfig; public compression: { enabled: boolean; referrerWhitelist?: string[] }; public csp: ICspConfig; + public xsrf: { disableProtection: boolean; whitelist: string[] }; /** * @internal @@ -137,7 +153,9 @@ export class HttpConfig { this.host = rawHttpConfig.host; this.port = rawHttpConfig.port; this.cors = rawHttpConfig.cors; + this.customResponseHeaders = rawHttpConfig.customResponseHeaders; this.maxPayload = rawHttpConfig.maxPayload; + this.name = rawHttpConfig.name; this.basePath = rawHttpConfig.basePath; this.keepaliveTimeout = rawHttpConfig.keepaliveTimeout; this.socketTimeout = rawHttpConfig.socketTimeout; @@ -145,5 +163,6 @@ export class HttpConfig { this.ssl = new SslConfig(rawHttpConfig.ssl || {}); this.compression = rawHttpConfig.compression; this.csp = new CspConfig(rawCspConfig); + this.xsrf = rawHttpConfig.xsrf; } } diff --git a/src/core/server/http/http_server.mocks.ts b/src/core/server/http/http_server.mocks.ts index ba742292e9e836..230a229b36888f 100644 --- a/src/core/server/http/http_server.mocks.ts +++ b/src/core/server/http/http_server.mocks.ts @@ -30,6 +30,9 @@ import { RouteMethod, KibanaResponseFactory, } from './router'; +import { OnPreResponseToolkit } from './lifecycle/on_pre_response'; +import { OnPostAuthToolkit } from './lifecycle/on_post_auth'; +import { OnPreAuthToolkit } from './lifecycle/on_pre_auth'; interface RequestFixtureOptions { headers?: Record; @@ -137,9 +140,19 @@ const createLifecycleResponseFactoryMock = (): jest.Mocked; + +const createToolkitMock = (): ToolkitMock => { + return { + next: jest.fn(), + rewriteUrl: jest.fn(), + }; +}; + export const httpServerMock = { createKibanaRequest: createKibanaRequestMock, createRawRequest: createRawRequestMock, createResponseFactory: createResponseFactoryMock, createLifecycleResponseFactory: createLifecycleResponseFactoryMock, + createToolkit: createToolkitMock, }; diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index 994a6cced89145..6b978b71c6f2b0 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -60,6 +60,12 @@ export interface HttpServerSetup { }; } +/** @internal */ +export type LifecycleRegistrar = Pick< + HttpServerSetup, + 'registerAuth' | 'registerOnPreAuth' | 'registerOnPostAuth' | 'registerOnPreResponse' +>; + export class HttpServer { private server?: Server; private config?: HttpConfig; diff --git a/src/core/server/http/http_service.test.mocks.ts b/src/core/server/http/http_service.test.mocks.ts index c147944f2b7d83..e18008d3b405df 100644 --- a/src/core/server/http/http_service.test.mocks.ts +++ b/src/core/server/http/http_service.test.mocks.ts @@ -27,3 +27,7 @@ jest.mock('./http_server', () => { HttpServer: mockHttpServer, }; }); + +jest.mock('./lifecycle_handlers', () => ({ + registerCoreHandlers: jest.fn(), +})); diff --git a/src/core/server/http/http_service.ts b/src/core/server/http/http_service.ts index 09982cf164a19a..ae9d53f9fd3db2 100644 --- a/src/core/server/http/http_service.ts +++ b/src/core/server/http/http_service.ts @@ -21,11 +21,10 @@ import { Observable, Subscription, combineLatest } from 'rxjs'; import { first, map } from 'rxjs/operators'; import { Server } from 'hapi'; -import { LoggerFactory } from '../logging'; import { CoreService } from '../../types'; - -import { Logger } from '../logging'; +import { Logger, LoggerFactory } from '../logging'; import { ContextSetup } from '../context'; +import { Env } from '../config'; import { CoreContext } from '../core_context'; import { PluginOpaqueId } from '../plugins'; import { CspConfigType, config as cspConfig } from '../csp'; @@ -43,6 +42,7 @@ import { } from './types'; import { RequestHandlerContext } from '../../server'; +import { registerCoreHandlers } from './lifecycle_handlers'; interface SetupDeps { context: ContextSetup; @@ -57,18 +57,20 @@ export class HttpService implements CoreService(httpConfig.path), - configService.atPath(cspConfig.path) - ).pipe(map(([http, csp]) => new HttpConfig(http, csp))); + configService.atPath(cspConfig.path), + ]).pipe(map(([http, csp]) => new HttpConfig(http, csp))); this.httpServer = new HttpServer(logger, 'Kibana'); this.httpsRedirectServer = new HttpsRedirectServer(logger.get('http', 'redirect', 'server')); } @@ -92,6 +94,9 @@ export class HttpService implements CoreService { + let server: HttpService; + let innerServer: HttpServerSetup['server']; + let router: IRouter; + + beforeEach(async () => { + const configService = configServiceMock.create(); + configService.atPath.mockReturnValue( + new BehaviorSubject({ + hosts: ['localhost'], + maxPayload: new ByteSizeValue(1024), + autoListen: true, + ssl: { + enabled: false, + }, + compression: { enabled: true }, + name: kibanaName, + customResponseHeaders: { + 'some-header': 'some-value', + }, + xsrf: { disableProtection: false, whitelist: [whitelistedTestPath] }, + } as any) + ); + server = createHttpServer({ configService }); + + const serverSetup = await server.setup(setupDeps); + router = serverSetup.createRouter('/'); + innerServer = serverSetup.server; + }, 30000); + + afterEach(async () => { + await server.stop(); + }); + + describe('versionCheck post-auth handler', () => { + const testRoute = '/version_check/test/route'; + + beforeEach(async () => { + router.get({ path: testRoute, validate: false }, (context, req, res) => { + return res.ok({ body: 'ok' }); + }); + await server.start(); + }); + + it('accepts requests with the correct version passed in the version header', async () => { + await supertest(innerServer.listener) + .get(testRoute) + .set(versionHeader, actualVersion) + .expect(200, 'ok'); + }); + + it('accepts requests that do not include a version header', async () => { + await supertest(innerServer.listener) + .get(testRoute) + .expect(200, 'ok'); + }); + + it('rejects requests with an incorrect version passed in the version header', async () => { + await supertest(innerServer.listener) + .get(testRoute) + .set(versionHeader, 'invalid-version') + .expect(400, /Browser client is out of date/); + }); + }); + + describe('customHeaders pre-response handler', () => { + const testRoute = '/custom_headers/test/route'; + const testErrorRoute = '/custom_headers/test/error_route'; + + beforeEach(async () => { + router.get({ path: testRoute, validate: false }, (context, req, res) => { + return res.ok({ body: 'ok' }); + }); + router.get({ path: testErrorRoute, validate: false }, (context, req, res) => { + return res.badRequest({ body: 'bad request' }); + }); + await server.start(); + }); + + it('adds the kbn-name header', async () => { + const result = await supertest(innerServer.listener) + .get(testRoute) + .expect(200, 'ok'); + const headers = result.header as Record; + expect(headers).toEqual( + expect.objectContaining({ + [nameHeader]: kibanaName, + }) + ); + }); + + it('adds the kbn-name header in case of error', async () => { + const result = await supertest(innerServer.listener) + .get(testErrorRoute) + .expect(400); + const headers = result.header as Record; + expect(headers).toEqual( + expect.objectContaining({ + [nameHeader]: kibanaName, + }) + ); + }); + + it('adds the custom headers', async () => { + const result = await supertest(innerServer.listener) + .get(testRoute) + .expect(200, 'ok'); + const headers = result.header as Record; + expect(headers).toEqual(expect.objectContaining({ 'some-header': 'some-value' })); + }); + + it('adds the custom headers in case of error', async () => { + const result = await supertest(innerServer.listener) + .get(testErrorRoute) + .expect(400); + const headers = result.header as Record; + expect(headers).toEqual(expect.objectContaining({ 'some-header': 'some-value' })); + }); + }); + + describe('xsrf post-auth handler', () => { + const testPath = '/xsrf/test/route'; + const destructiveMethods = ['POST', 'PUT', 'DELETE']; + const nonDestructiveMethods = ['GET', 'HEAD']; + + const getSupertest = (method: string, path: string): supertest.Test => { + return (supertest(innerServer.listener) as any)[method.toLowerCase()](path) as supertest.Test; + }; + + beforeEach(async () => { + router.get({ path: testPath, validate: false }, (context, req, res) => { + return res.ok({ body: 'ok' }); + }); + + destructiveMethods.forEach(method => { + ((router as any)[method.toLowerCase()] as RouteRegistrar)( + { path: testPath, validate: false }, + (context, req, res) => { + return res.ok({ body: 'ok' }); + } + ); + ((router as any)[method.toLowerCase()] as RouteRegistrar)( + { path: whitelistedTestPath, validate: false }, + (context, req, res) => { + return res.ok({ body: 'ok' }); + } + ); + }); + + await server.start(); + }); + + nonDestructiveMethods.forEach(method => { + describe(`When using non-destructive ${method} method`, () => { + it('accepts requests without a token', async () => { + await getSupertest(method.toLowerCase(), testPath).expect( + 200, + method === 'HEAD' ? undefined : 'ok' + ); + }); + + it('accepts requests with the xsrf header', async () => { + await getSupertest(method.toLowerCase(), testPath) + .set(xsrfHeader, 'anything') + .expect(200, method === 'HEAD' ? undefined : 'ok'); + }); + }); + }); + + destructiveMethods.forEach(method => { + describe(`When using destructive ${method} method`, () => { + it('accepts requests with the xsrf header', async () => { + await getSupertest(method.toLowerCase(), testPath) + .set(xsrfHeader, 'anything') + .expect(200, 'ok'); + }); + + it('accepts requests with the version header', async () => { + await getSupertest(method.toLowerCase(), testPath) + .set(versionHeader, actualVersion) + .expect(200, 'ok'); + }); + + it('rejects requests without either an xsrf or version header', async () => { + await getSupertest(method.toLowerCase(), testPath).expect(400, { + statusCode: 400, + error: 'Bad Request', + message: 'Request must contain a kbn-xsrf header.', + }); + }); + + it('accepts whitelisted requests without either an xsrf or version header', async () => { + await getSupertest(method.toLowerCase(), whitelistedTestPath).expect(200, 'ok'); + }); + }); + }); + }); +}); diff --git a/src/core/server/http/lifecycle_handlers.test.ts b/src/core/server/http/lifecycle_handlers.test.ts new file mode 100644 index 00000000000000..48a6973b741ba0 --- /dev/null +++ b/src/core/server/http/lifecycle_handlers.test.ts @@ -0,0 +1,269 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + createCustomHeadersPreResponseHandler, + createVersionCheckPostAuthHandler, + createXsrfPostAuthHandler, +} from './lifecycle_handlers'; +import { httpServerMock } from './http_server.mocks'; +import { HttpConfig } from './http_config'; +import { KibanaRequest, RouteMethod } from './router'; + +const createConfig = (partial: Partial): HttpConfig => partial as HttpConfig; + +const forgeRequest = ({ + headers = {}, + path = '/', + method = 'get', +}: Partial<{ + headers: Record; + path: string; + method: RouteMethod; +}>): KibanaRequest => { + return httpServerMock.createKibanaRequest({ headers, path, method }); +}; + +describe('xsrf post-auth handler', () => { + let toolkit: ReturnType; + let responseFactory: ReturnType; + + beforeEach(() => { + toolkit = httpServerMock.createToolkit(); + responseFactory = httpServerMock.createLifecycleResponseFactory(); + }); + + describe('non destructive methods', () => { + it('accepts requests without version or xsrf header', () => { + const config = createConfig({ xsrf: { whitelist: [], disableProtection: false } }); + const handler = createXsrfPostAuthHandler(config); + const request = forgeRequest({ method: 'get', headers: {} }); + + toolkit.next.mockReturnValue('next' as any); + + const result = handler(request, responseFactory, toolkit); + + expect(responseFactory.badRequest).not.toHaveBeenCalled(); + expect(toolkit.next).toHaveBeenCalledTimes(1); + expect(result).toEqual('next'); + }); + }); + + describe('destructive methods', () => { + it('accepts requests with xsrf header', () => { + const config = createConfig({ xsrf: { whitelist: [], disableProtection: false } }); + const handler = createXsrfPostAuthHandler(config); + const request = forgeRequest({ method: 'post', headers: { 'kbn-xsrf': 'xsrf' } }); + + toolkit.next.mockReturnValue('next' as any); + + const result = handler(request, responseFactory, toolkit); + + expect(responseFactory.badRequest).not.toHaveBeenCalled(); + expect(toolkit.next).toHaveBeenCalledTimes(1); + expect(result).toEqual('next'); + }); + + it('accepts requests with version header', () => { + const config = createConfig({ xsrf: { whitelist: [], disableProtection: false } }); + const handler = createXsrfPostAuthHandler(config); + const request = forgeRequest({ method: 'post', headers: { 'kbn-version': 'some-version' } }); + + toolkit.next.mockReturnValue('next' as any); + + const result = handler(request, responseFactory, toolkit); + + expect(responseFactory.badRequest).not.toHaveBeenCalled(); + expect(toolkit.next).toHaveBeenCalledTimes(1); + expect(result).toEqual('next'); + }); + + it('returns a bad request if called without xsrf or version header', () => { + const config = createConfig({ xsrf: { whitelist: [], disableProtection: false } }); + const handler = createXsrfPostAuthHandler(config); + const request = forgeRequest({ method: 'post' }); + + responseFactory.badRequest.mockReturnValue('badRequest' as any); + + const result = handler(request, responseFactory, toolkit); + + expect(toolkit.next).not.toHaveBeenCalled(); + expect(responseFactory.badRequest).toHaveBeenCalledTimes(1); + expect(responseFactory.badRequest.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "body": "Request must contain a kbn-xsrf header.", + } + `); + expect(result).toEqual('badRequest'); + }); + + it('accepts requests if protection is disabled', () => { + const config = createConfig({ xsrf: { whitelist: [], disableProtection: true } }); + const handler = createXsrfPostAuthHandler(config); + const request = forgeRequest({ method: 'post', headers: {} }); + + toolkit.next.mockReturnValue('next' as any); + + const result = handler(request, responseFactory, toolkit); + + expect(responseFactory.badRequest).not.toHaveBeenCalled(); + expect(toolkit.next).toHaveBeenCalledTimes(1); + expect(result).toEqual('next'); + }); + + it('accepts requests if path is whitelisted', () => { + const config = createConfig({ + xsrf: { whitelist: ['/some-path'], disableProtection: false }, + }); + const handler = createXsrfPostAuthHandler(config); + const request = forgeRequest({ method: 'post', headers: {}, path: '/some-path' }); + + toolkit.next.mockReturnValue('next' as any); + + const result = handler(request, responseFactory, toolkit); + + expect(responseFactory.badRequest).not.toHaveBeenCalled(); + expect(toolkit.next).toHaveBeenCalledTimes(1); + expect(result).toEqual('next'); + }); + }); +}); + +describe('versionCheck post-auth handler', () => { + let toolkit: ReturnType; + let responseFactory: ReturnType; + + beforeEach(() => { + toolkit = httpServerMock.createToolkit(); + responseFactory = httpServerMock.createLifecycleResponseFactory(); + }); + + it('forward the request to the next interceptor if header matches', () => { + const handler = createVersionCheckPostAuthHandler('actual-version'); + const request = forgeRequest({ headers: { 'kbn-version': 'actual-version' } }); + + toolkit.next.mockReturnValue('next' as any); + + const result = handler(request, responseFactory, toolkit); + + expect(toolkit.next).toHaveBeenCalledTimes(1); + expect(responseFactory.badRequest).not.toHaveBeenCalled(); + expect(result).toBe('next'); + }); + + it('returns a badRequest error if header does not match', () => { + const handler = createVersionCheckPostAuthHandler('actual-version'); + const request = forgeRequest({ headers: { 'kbn-version': 'another-version' } }); + + responseFactory.badRequest.mockReturnValue('badRequest' as any); + + const result = handler(request, responseFactory, toolkit); + + expect(toolkit.next).not.toHaveBeenCalled(); + expect(responseFactory.badRequest).toHaveBeenCalledTimes(1); + expect(responseFactory.badRequest.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "body": Object { + "attributes": Object { + "expected": "actual-version", + "got": "another-version", + }, + "message": "Browser client is out of date, please refresh the page (\\"kbn-version\\" header was \\"another-version\\" but should be \\"actual-version\\")", + }, + } + `); + expect(result).toBe('badRequest'); + }); + + it('forward the request to the next interceptor if header is not present', () => { + const handler = createVersionCheckPostAuthHandler('actual-version'); + const request = forgeRequest({ headers: {} }); + + toolkit.next.mockReturnValue('next' as any); + + const result = handler(request, responseFactory, toolkit); + + expect(toolkit.next).toHaveBeenCalledTimes(1); + expect(responseFactory.badRequest).not.toHaveBeenCalled(); + expect(result).toBe('next'); + }); +}); + +describe('customHeaders pre-response handler', () => { + let toolkit: ReturnType; + + beforeEach(() => { + toolkit = httpServerMock.createToolkit(); + }); + + it('adds the kbn-name header to the response', () => { + const config = createConfig({ name: 'my-server-name' }); + const handler = createCustomHeadersPreResponseHandler(config as HttpConfig); + + handler({} as any, {} as any, toolkit); + + expect(toolkit.next).toHaveBeenCalledTimes(1); + expect(toolkit.next).toHaveBeenCalledWith({ headers: { 'kbn-name': 'my-server-name' } }); + }); + + it('adds the custom headers defined in the configuration', () => { + const config = createConfig({ + name: 'my-server-name', + customResponseHeaders: { + headerA: 'value-A', + headerB: 'value-B', + }, + }); + const handler = createCustomHeadersPreResponseHandler(config as HttpConfig); + + handler({} as any, {} as any, toolkit); + + expect(toolkit.next).toHaveBeenCalledTimes(1); + expect(toolkit.next).toHaveBeenCalledWith({ + headers: { + 'kbn-name': 'my-server-name', + headerA: 'value-A', + headerB: 'value-B', + }, + }); + }); + + it('preserve the kbn-name value from server.name if definied in custom headders ', () => { + const config = createConfig({ + name: 'my-server-name', + customResponseHeaders: { + 'kbn-name': 'custom-name', + headerA: 'value-A', + headerB: 'value-B', + }, + }); + const handler = createCustomHeadersPreResponseHandler(config as HttpConfig); + + handler({} as any, {} as any, toolkit); + + expect(toolkit.next).toHaveBeenCalledTimes(1); + expect(toolkit.next).toHaveBeenCalledWith({ + headers: { + 'kbn-name': 'my-server-name', + headerA: 'value-A', + headerB: 'value-B', + }, + }); + }); +}); diff --git a/src/core/server/http/lifecycle_handlers.ts b/src/core/server/http/lifecycle_handlers.ts new file mode 100644 index 00000000000000..ee877ee031a2bb --- /dev/null +++ b/src/core/server/http/lifecycle_handlers.ts @@ -0,0 +1,93 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { OnPostAuthHandler } from './lifecycle/on_post_auth'; +import { OnPreResponseHandler } from './lifecycle/on_pre_response'; +import { HttpConfig } from './http_config'; +import { Env } from '../config'; +import { LifecycleRegistrar } from './http_server'; + +const VERSION_HEADER = 'kbn-version'; +const XSRF_HEADER = 'kbn-xsrf'; +const KIBANA_NAME_HEADER = 'kbn-name'; + +export const createXsrfPostAuthHandler = (config: HttpConfig): OnPostAuthHandler => { + const { whitelist, disableProtection } = config.xsrf; + + return (request, response, toolkit) => { + if (disableProtection || whitelist.includes(request.route.path)) { + return toolkit.next(); + } + + const isSafeMethod = request.route.method === 'get' || request.route.method === 'head'; + const hasVersionHeader = VERSION_HEADER in request.headers; + const hasXsrfHeader = XSRF_HEADER in request.headers; + + if (!isSafeMethod && !hasVersionHeader && !hasXsrfHeader) { + return response.badRequest({ body: `Request must contain a ${XSRF_HEADER} header.` }); + } + + return toolkit.next(); + }; +}; + +export const createVersionCheckPostAuthHandler = (kibanaVersion: string): OnPostAuthHandler => { + return (request, response, toolkit) => { + const requestVersion = request.headers[VERSION_HEADER]; + if (requestVersion && requestVersion !== kibanaVersion) { + return response.badRequest({ + body: { + message: + `Browser client is out of date, please refresh the page ` + + `("${VERSION_HEADER}" header was "${requestVersion}" but should be "${kibanaVersion}")`, + attributes: { + expected: kibanaVersion, + got: requestVersion, + }, + }, + }); + } + + return toolkit.next(); + }; +}; + +export const createCustomHeadersPreResponseHandler = (config: HttpConfig): OnPreResponseHandler => { + const serverName = config.name; + const customHeaders = config.customResponseHeaders; + + return (request, response, toolkit) => { + const additionalHeaders = { + ...customHeaders, + [KIBANA_NAME_HEADER]: serverName, + }; + + return toolkit.next({ headers: additionalHeaders }); + }; +}; + +export const registerCoreHandlers = ( + registrar: LifecycleRegistrar, + config: HttpConfig, + env: Env +) => { + registrar.registerOnPreResponse(createCustomHeadersPreResponseHandler(config)); + registrar.registerOnPostAuth(createXsrfPostAuthHandler(config)); + registrar.registerOnPostAuth(createVersionCheckPostAuthHandler(env.packageInfo.version)); +}; diff --git a/src/core/server/http/test_utils.ts b/src/core/server/http/test_utils.ts index e0a15cdc6e8391..ffdc04d156ca0e 100644 --- a/src/core/server/http/test_utils.ts +++ b/src/core/server/http/test_utils.ts @@ -41,6 +41,10 @@ configService.atPath.mockReturnValue( enabled: false, }, compression: { enabled: true }, + xsrf: { + disableProtection: true, + whitelist: [], + }, } as any) ); diff --git a/src/core/server/legacy/config/__snapshots__/legacy_object_to_config_adapter.test.ts.snap b/src/core/server/legacy/config/__snapshots__/legacy_object_to_config_adapter.test.ts.snap index 3161dd06cf3b65..74ecaa9f09c0e1 100644 --- a/src/core/server/legacy/config/__snapshots__/legacy_object_to_config_adapter.test.ts.snap +++ b/src/core/server/legacy/config/__snapshots__/legacy_object_to_config_adapter.test.ts.snap @@ -8,9 +8,13 @@ Object { "enabled": true, }, "cors": false, + "customResponseHeaders": Object { + "custom-header": "custom-value", + }, "host": "host", "keepaliveTimeout": 5000, "maxPayload": 1000, + "name": "kibana-hostname", "port": 1234, "rewriteBasePath": false, "socketTimeout": 2000, @@ -20,6 +24,10 @@ Object { "someNewValue": "new", }, "uuid": undefined, + "xsrf": Object { + "disableProtection": false, + "whitelist": Array [], + }, } `; @@ -31,9 +39,13 @@ Object { "enabled": true, }, "cors": false, + "customResponseHeaders": Object { + "custom-header": "custom-value", + }, "host": "host", "keepaliveTimeout": 5000, "maxPayload": 1000, + "name": "kibana-hostname", "port": 1234, "rewriteBasePath": false, "socketTimeout": 2000, @@ -43,6 +55,10 @@ Object { "key": "key", }, "uuid": undefined, + "xsrf": Object { + "disableProtection": false, + "whitelist": Array [], + }, } `; diff --git a/src/core/server/legacy/config/legacy_object_to_config_adapter.test.ts b/src/core/server/legacy/config/legacy_object_to_config_adapter.test.ts index db2bc117280ca0..1c51564187442a 100644 --- a/src/core/server/legacy/config/legacy_object_to_config_adapter.test.ts +++ b/src/core/server/legacy/config/legacy_object_to_config_adapter.test.ts @@ -80,9 +80,11 @@ describe('#get', () => { test('correctly handles server config.', () => { const configAdapter = new LegacyObjectToConfigAdapter({ server: { + name: 'kibana-hostname', autoListen: true, basePath: '/abc', cors: false, + customResponseHeaders: { 'custom-header': 'custom-value' }, host: 'host', maxPayloadBytes: 1000, keepaliveTimeout: 5000, @@ -92,14 +94,20 @@ describe('#get', () => { ssl: { enabled: true, keyPassphrase: 'some-phrase', someNewValue: 'new' }, compression: { enabled: true }, someNotSupportedValue: 'val', + xsrf: { + disableProtection: false, + whitelist: [], + }, }, }); const configAdapterWithDisabledSSL = new LegacyObjectToConfigAdapter({ server: { + name: 'kibana-hostname', autoListen: true, basePath: '/abc', cors: false, + customResponseHeaders: { 'custom-header': 'custom-value' }, host: 'host', maxPayloadBytes: 1000, keepaliveTimeout: 5000, @@ -109,6 +117,10 @@ describe('#get', () => { ssl: { enabled: false, certificate: 'cert', key: 'key' }, compression: { enabled: true }, someNotSupportedValue: 'val', + xsrf: { + disableProtection: false, + whitelist: [], + }, }, }); diff --git a/src/core/server/legacy/config/legacy_object_to_config_adapter.ts b/src/core/server/legacy/config/legacy_object_to_config_adapter.ts index 458c1f1f119ee4..30bb150e6c15a8 100644 --- a/src/core/server/legacy/config/legacy_object_to_config_adapter.ts +++ b/src/core/server/legacy/config/legacy_object_to_config_adapter.ts @@ -60,13 +60,15 @@ export class LegacyObjectToConfigAdapter extends ObjectToConfigAdapter { private static transformServer(configValue: any = {}) { // TODO: New platform uses just a subset of `server` config from the legacy platform, - // new values will be exposed once we need them (eg. customResponseHeaders or xsrf). + // new values will be exposed once we need them return { autoListen: configValue.autoListen, basePath: configValue.basePath, cors: configValue.cors, + customResponseHeaders: configValue.customResponseHeaders, host: configValue.host, maxPayload: configValue.maxPayloadBytes, + name: configValue.name, port: configValue.port, rewriteBasePath: configValue.rewriteBasePath, ssl: configValue.ssl, @@ -74,6 +76,7 @@ export class LegacyObjectToConfigAdapter extends ObjectToConfigAdapter { socketTimeout: configValue.socketTimeout, compression: configValue.compression, uuid: configValue.uuid, + xsrf: configValue.xsrf, }; } diff --git a/src/legacy/server/config/schema.js b/src/legacy/server/config/schema.js index 183904ff35985e..a18cb7de5a61b9 100644 --- a/src/legacy/server/config/schema.js +++ b/src/legacy/server/config/schema.js @@ -70,19 +70,6 @@ export default () => server: Joi.object({ name: Joi.string().default(os.hostname()), - customResponseHeaders: Joi.object() - .unknown(true) - .default({}), - xsrf: Joi.object({ - disableProtection: Joi.boolean().default(false), - whitelist: Joi.array() - .items(Joi.string().regex(/^\//, 'start with a slash')) - .default([]), - token: Joi.string() - .optional() - .notes('Deprecated'), - }).default(), - // keep them for BWC, remove when not used in Legacy. // validation should be in sync with one in New platform. // https://github.com/elastic/kibana/blob/master/src/core/server/http/http_config.ts @@ -102,12 +89,14 @@ export default () => autoListen: HANDLED_IN_NEW_PLATFORM, cors: HANDLED_IN_NEW_PLATFORM, + customResponseHeaders: HANDLED_IN_NEW_PLATFORM, keepaliveTimeout: HANDLED_IN_NEW_PLATFORM, maxPayloadBytes: HANDLED_IN_NEW_PLATFORM, socketTimeout: HANDLED_IN_NEW_PLATFORM, ssl: HANDLED_IN_NEW_PLATFORM, compression: HANDLED_IN_NEW_PLATFORM, uuid: HANDLED_IN_NEW_PLATFORM, + xsrf: HANDLED_IN_NEW_PLATFORM, }).default(), uiSettings: HANDLED_IN_NEW_PLATFORM, diff --git a/src/legacy/server/config/schema.test.js b/src/legacy/server/config/schema.test.js index 1207a05a47497a..03d2fe53c2ce72 100644 --- a/src/legacy/server/config/schema.test.js +++ b/src/legacy/server/config/schema.test.js @@ -19,7 +19,6 @@ import schemaProvider from './schema'; import Joi from 'joi'; -import { set } from 'lodash'; describe('Config schema', function() { let schema; @@ -100,60 +99,5 @@ describe('Config schema', function() { expect(error.details[0]).toHaveProperty('path', ['server', 'rewriteBasePath']); }); }); - - describe('xsrf', () => { - it('disableProtection is `false` by default.', () => { - const { - error, - value: { - server: { - xsrf: { disableProtection }, - }, - }, - } = validate({}); - expect(error).toBe(null); - expect(disableProtection).toBe(false); - }); - - it('whitelist is empty by default.', () => { - const { - value: { - server: { - xsrf: { whitelist }, - }, - }, - } = validate({}); - expect(whitelist).toBeInstanceOf(Array); - expect(whitelist).toHaveLength(0); - }); - - it('whitelist rejects paths that do not start with a slash.', () => { - const config = {}; - set(config, 'server.xsrf.whitelist', ['path/to']); - - const { error } = validate(config); - expect(error).toBeInstanceOf(Object); - expect(error).toHaveProperty('details'); - expect(error.details[0]).toHaveProperty('path', ['server', 'xsrf', 'whitelist', 0]); - }); - - it('whitelist accepts paths that start with a slash.', () => { - const config = {}; - set(config, 'server.xsrf.whitelist', ['/path/to']); - - const { - error, - value: { - server: { - xsrf: { whitelist }, - }, - }, - } = validate(config); - expect(error).toBe(null); - expect(whitelist).toBeInstanceOf(Array); - expect(whitelist).toHaveLength(1); - expect(whitelist).toContain('/path/to'); - }); - }); }); }); diff --git a/src/legacy/server/http/index.js b/src/legacy/server/http/index.js index 9b5ce2046c5d32..265d71e95b301f 100644 --- a/src/legacy/server/http/index.js +++ b/src/legacy/server/http/index.js @@ -22,11 +22,9 @@ import { resolve } from 'path'; import _ from 'lodash'; import Boom from 'boom'; -import { setupVersionCheck } from './version_check'; import { registerHapiPlugins } from './register_hapi_plugins'; import { setupBasePathProvider } from './setup_base_path_provider'; import { setupDefaultRouteProvider } from './setup_default_route_provider'; -import { setupXsrf } from './xsrf'; export default async function(kbnServer, server, config) { server = kbnServer.server; @@ -62,29 +60,6 @@ export default async function(kbnServer, server, config) { }); }); - // attach the app name to the server, so we can be sure we are actually talking to kibana - server.ext('onPreResponse', function onPreResponse(req, h) { - const response = req.response; - - const customHeaders = { - ...config.get('server.customResponseHeaders'), - 'kbn-name': kbnServer.name, - }; - - if (response.isBoom) { - response.output.headers = { - ...response.output.headers, - ...customHeaders, - }; - } else { - Object.keys(customHeaders).forEach(name => { - response.header(name, customHeaders[name]); - }); - } - - return h.continue; - }); - server.route({ path: '/', method: 'GET', @@ -116,7 +91,4 @@ export default async function(kbnServer, server, config) { // Expose static assets server.exposeStaticDir('/ui/{path*}', resolve(__dirname, '../../ui/public/assets')); - - setupVersionCheck(server, config); - setupXsrf(server, config); } diff --git a/src/legacy/server/http/integration_tests/version_check.test.js b/src/legacy/server/http/integration_tests/version_check.test.js deleted file mode 100644 index 8d71c98d649693..00000000000000 --- a/src/legacy/server/http/integration_tests/version_check.test.js +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { resolve } from 'path'; -import * as kbnTestServer from '../../../../test_utils/kbn_server'; - -const src = resolve.bind(null, __dirname, '../../../../../src'); - -const versionHeader = 'kbn-version'; -const version = require(src('../package.json')).version; // eslint-disable-line import/no-dynamic-require - -describe('version_check request filter', function() { - let root; - beforeAll(async () => { - root = kbnTestServer.createRoot(); - - await root.setup(); - await root.start(); - - kbnTestServer.getKbnServer(root).server.route({ - path: '/version_check/test/route', - method: 'GET', - handler: function() { - return 'ok'; - }, - }); - }, 30000); - - afterAll(async () => await root.shutdown()); - - it('accepts requests with the correct version passed in the version header', async function() { - await kbnTestServer.request - .get(root, '/version_check/test/route') - .set(versionHeader, version) - .expect(200, 'ok'); - }); - - it('rejects requests with an incorrect version passed in the version header', async function() { - await kbnTestServer.request - .get(root, '/version_check/test/route') - .set(versionHeader, `invalid:${version}`) - .expect(400, /"Browser client is out of date/); - }); - - it('accepts requests that do not include a version header', async function() { - await kbnTestServer.request.get(root, '/version_check/test/route').expect(200, 'ok'); - }); -}); diff --git a/src/legacy/server/http/integration_tests/xsrf.test.js b/src/legacy/server/http/integration_tests/xsrf.test.js deleted file mode 100644 index a06f4eec4fd5ca..00000000000000 --- a/src/legacy/server/http/integration_tests/xsrf.test.js +++ /dev/null @@ -1,145 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { resolve } from 'path'; -import * as kbnTestServer from '../../../../test_utils/kbn_server'; - -const destructiveMethods = ['POST', 'PUT', 'DELETE']; -const src = resolve.bind(null, __dirname, '../../../../../src'); - -const xsrfHeader = 'kbn-xsrf'; -const versionHeader = 'kbn-version'; -const testPath = '/xsrf/test/route'; -const whitelistedTestPath = '/xsrf/test/route/whitelisted'; -const actualVersion = require(src('../package.json')).version; // eslint-disable-line import/no-dynamic-require - -describe('xsrf request filter', () => { - let root; - beforeAll(async () => { - root = kbnTestServer.createRoot({ - server: { - xsrf: { disableProtection: false, whitelist: [whitelistedTestPath] }, - }, - }); - - await root.setup(); - await root.start(); - - const kbnServer = kbnTestServer.getKbnServer(root); - kbnServer.server.route({ - path: testPath, - method: 'GET', - handler: async function() { - return 'ok'; - }, - }); - - kbnServer.server.route({ - path: testPath, - method: destructiveMethods, - config: { - // Disable payload parsing to make HapiJS server accept any content-type header. - payload: { - parse: false, - }, - validate: { payload: null }, - }, - handler: async function() { - return 'ok'; - }, - }); - - kbnServer.server.route({ - path: whitelistedTestPath, - method: destructiveMethods, - config: { - // Disable payload parsing to make HapiJS server accept any content-type header. - payload: { - parse: false, - }, - validate: { payload: null }, - }, - handler: async function() { - return 'ok'; - }, - }); - }, 30000); - - afterAll(async () => await root.shutdown()); - - describe(`nonDestructiveMethod: GET`, function() { - it('accepts requests without a token', async function() { - await kbnTestServer.request.get(root, testPath).expect(200, 'ok'); - }); - - it('accepts requests with the xsrf header', async function() { - await kbnTestServer.request - .get(root, testPath) - .set(xsrfHeader, 'anything') - .expect(200, 'ok'); - }); - }); - - describe(`nonDestructiveMethod: HEAD`, function() { - it('accepts requests without a token', async function() { - await kbnTestServer.request.head(root, testPath).expect(200, undefined); - }); - - it('accepts requests with the xsrf header', async function() { - await kbnTestServer.request - .head(root, testPath) - .set(xsrfHeader, 'anything') - .expect(200, undefined); - }); - }); - - for (const method of destructiveMethods) { - // eslint-disable-next-line no-loop-func - describe(`destructiveMethod: ${method}`, function() { - it('accepts requests with the xsrf header', async function() { - await kbnTestServer.request[method.toLowerCase()](root, testPath) - .set(xsrfHeader, 'anything') - .expect(200, 'ok'); - }); - - // this is still valid for existing csrf protection support - // it does not actually do any validation on the version value itself - it('accepts requests with the version header', async function() { - await kbnTestServer.request[method.toLowerCase()](root, testPath) - .set(versionHeader, actualVersion) - .expect(200, 'ok'); - }); - - it('rejects requests without either an xsrf or version header', async function() { - await kbnTestServer.request[method.toLowerCase()](root, testPath).expect(400, { - statusCode: 400, - error: 'Bad Request', - message: 'Request must contain a kbn-xsrf header.', - }); - }); - - it('accepts whitelisted requests without either an xsrf or version header', async function() { - await kbnTestServer.request[method.toLowerCase()](root, whitelistedTestPath).expect( - 200, - 'ok' - ); - }); - }); - } -}); diff --git a/src/legacy/server/http/version_check.js b/src/legacy/server/http/version_check.js deleted file mode 100644 index 12666c9a0f3f68..00000000000000 --- a/src/legacy/server/http/version_check.js +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { badRequest } from 'boom'; - -export function setupVersionCheck(server, config) { - const versionHeader = 'kbn-version'; - const actualVersion = config.get('pkg.version'); - - server.ext('onPostAuth', function onPostAuthVersionCheck(req, h) { - const versionRequested = req.headers[versionHeader]; - - if (versionRequested && versionRequested !== actualVersion) { - throw badRequest( - `Browser client is out of date, \ - please refresh the page ("${versionHeader}" header was "${versionRequested}" but should be "${actualVersion}")`, - { expected: actualVersion, got: versionRequested } - ); - } - - return h.continue; - }); -} diff --git a/src/legacy/server/http/xsrf.js b/src/legacy/server/http/xsrf.js deleted file mode 100644 index 79ac3af6d9f905..00000000000000 --- a/src/legacy/server/http/xsrf.js +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { badRequest } from 'boom'; - -export function setupXsrf(server, config) { - const disabled = config.get('server.xsrf.disableProtection'); - const whitelist = config.get('server.xsrf.whitelist'); - const versionHeader = 'kbn-version'; - const xsrfHeader = 'kbn-xsrf'; - - server.ext('onPostAuth', function onPostAuthXsrf(req, h) { - if (disabled) { - return h.continue; - } - - if (whitelist.includes(req.path)) { - return h.continue; - } - - const isSafeMethod = req.method === 'get' || req.method === 'head'; - const hasVersionHeader = versionHeader in req.headers; - const hasXsrfHeader = xsrfHeader in req.headers; - - if (!isSafeMethod && !hasVersionHeader && !hasXsrfHeader) { - throw badRequest(`Request must contain a ${xsrfHeader} header.`); - } - - return h.continue; - }); -} diff --git a/src/test_utils/kbn_server.ts b/src/test_utils/kbn_server.ts index 43c6b4378ed27a..e1b4a823e7e871 100644 --- a/src/test_utils/kbn_server.ts +++ b/src/test_utils/kbn_server.ts @@ -37,7 +37,7 @@ import { Root } from '../core/server/root'; import KbnServer from '../legacy/server/kbn_server'; import { CallCluster } from '../legacy/core_plugins/elasticsearch'; -type HttpMethod = 'delete' | 'get' | 'head' | 'post' | 'put'; +export type HttpMethod = 'delete' | 'get' | 'head' | 'post' | 'put'; const DEFAULTS_SETTINGS = { server: { @@ -97,7 +97,7 @@ export function createRootWithSettings( * @param method * @param path */ -function getSupertest(root: Root, method: HttpMethod, path: string) { +export function getSupertest(root: Root, method: HttpMethod, path: string) { const testUserCredentials = Buffer.from(`${kibanaTestUser.username}:${kibanaTestUser.password}`); return supertest((root as any).server.http.httpServer.server.listener) [method](path) From 6ce2818f88c3c1f56c051c42ab0973b845c9e9cb Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Mon, 6 Jan 2020 15:21:44 +0100 Subject: [PATCH 6/7] [Console] Fix OSS build (#53885) * Move fp-ts and immer to "." package.json * Revert "Move fp-ts and immer to "." package.json" This reverts commit b876df0d543d7537b84590c7759cdea36014756c. * Second attempt, fp-ts and immer -> root * fp-ts -> 2.3.1 * Revert x-pack/package.json * Update fp-ts in x-pack/package.json Co-authored-by: Elastic Machine --- package.json | 2 ++ x-pack/package.json | 2 +- yarn.lock | 8 ++++---- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 4f8229333e5a09..99151f33962c44 100644 --- a/package.json +++ b/package.json @@ -174,6 +174,7 @@ "fast-deep-equal": "^3.1.1", "file-loader": "4.2.0", "font-awesome": "4.7.0", + "fp-ts": "^2.3.1", "getos": "^3.1.0", "glob": "^7.1.2", "glob-all": "^3.1.0", @@ -188,6 +189,7 @@ "hoek": "^5.0.4", "http-proxy-agent": "^2.1.0", "https-proxy-agent": "^2.2.2", + "immer": "^1.5.0", "inert": "^5.1.0", "inline-style": "^2.0.0", "joi": "^13.5.2", diff --git a/x-pack/package.json b/x-pack/package.json index c0e7a7c86cf0b1..110db56c5d4ed4 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -230,7 +230,7 @@ "file-type": "^10.9.0", "font-awesome": "4.7.0", "formsy-react": "^1.1.5", - "fp-ts": "^2.0.5", + "fp-ts": "^2.3.1", "geojson-rewind": "^0.3.1", "get-port": "4.2.0", "getos": "^3.1.0", diff --git a/yarn.lock b/yarn.lock index 7e90744195608e..0026370927fe10 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13099,10 +13099,10 @@ fp-ts@^1.0.0: resolved "https://registry.yarnpkg.com/fp-ts/-/fp-ts-1.12.0.tgz#d333310e4ac104cdcb6bea47908e381bb09978e7" integrity sha512-fWwnAgVlTsV26Ruo9nx+fxNHIm6l1puE1VJ/C0XJ3nRQJJJIgRHYw6sigB3MuNFZL1o4fpGlhwFhcbxHK0RsOA== -fp-ts@^2.0.5: - version "2.0.5" - resolved "https://registry.yarnpkg.com/fp-ts/-/fp-ts-2.0.5.tgz#9560d8a6a4f53cbda9f9b31ed8d1458e41939e07" - integrity sha512-opI5r+rVlpZE7Rhk0YtqsrmxGkbIw0dRNqGca8FEAMMnjomXotG+R9QkLQg20onx7R8qhepAn4CCOP8usma/Xw== +fp-ts@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/fp-ts/-/fp-ts-2.3.1.tgz#8068bfcca118227932941101e062134d7ecd9119" + integrity sha512-KevPBnYt0aaJiuUzmU9YIxjrhC9AgJ8CLtLlXmwArovlNTeYM5NtEoKd86B0wHd7FIbzeE8sNXzCoYIOr7e6Iw== fragment-cache@^0.2.1: version "0.2.1" From 785b9169174ed5793eb4f5c79fa3b32dc9c6c584 Mon Sep 17 00:00:00 2001 From: Gidi Meir Morris Date: Mon, 6 Jan 2020 14:52:06 +0000 Subject: [PATCH 7/7] allows Alerts to recover gracefully from Executor errors (#53688) Prevents errors in Alert Executors from forcing their underlying tasks into a zombie state. --- .../alert_instance.test.ts | 0 .../{lib => alert_instance}/alert_instance.ts | 2 +- .../create_alert_instance_factory.test.ts | 0 .../create_alert_instance_factory.ts | 0 .../alerting/server/alert_instance/index.ts | 8 + .../server/alert_type_registry.test.ts | 2 +- .../alerting/server/alert_type_registry.ts | 4 +- .../{lib => }/alerts_client_factory.test.ts | 26 +- .../server/{lib => }/alerts_client_factory.ts | 12 +- .../plugins/alerting/server/lib/index.ts | 6 +- .../alerting/server/lib/result_type.ts | 54 +++ .../server/lib/task_runner_factory.test.ts | 345 --------------- .../server/lib/task_runner_factory.ts | 190 --------- .../legacy/plugins/alerting/server/plugin.ts | 3 +- .../create_execution_handler.test.ts | 0 .../create_execution_handler.ts | 0 .../get_next_run_at.test.ts | 0 .../{lib => task_runner}/get_next_run_at.ts | 2 +- .../alerting/server/task_runner/index.ts | 7 + .../server/task_runner/task_runner.test.ts | 400 ++++++++++++++++++ .../server/task_runner/task_runner.ts | 241 +++++++++++ .../task_runner/task_runner_factory.test.ts | 87 ++++ .../server/task_runner/task_runner_factory.ts | 46 ++ .../transform_action_params.test.ts | 0 .../transform_action_params.ts | 0 .../legacy/plugins/alerting/server/types.ts | 2 +- .../common/lib/alert_utils.ts | 47 +- .../common/lib/index.ts | 1 + .../common/lib/test_assertions.ts | 17 + .../tests/alerting/update.ts | 15 +- .../spaces_only/tests/alerting/alerts.ts | 40 ++ 31 files changed, 982 insertions(+), 575 deletions(-) rename x-pack/legacy/plugins/alerting/server/{lib => alert_instance}/alert_instance.test.ts (100%) rename x-pack/legacy/plugins/alerting/server/{lib => alert_instance}/alert_instance.ts (97%) rename x-pack/legacy/plugins/alerting/server/{lib => alert_instance}/create_alert_instance_factory.test.ts (100%) rename x-pack/legacy/plugins/alerting/server/{lib => alert_instance}/create_alert_instance_factory.ts (100%) create mode 100644 x-pack/legacy/plugins/alerting/server/alert_instance/index.ts rename x-pack/legacy/plugins/alerting/server/{lib => }/alerts_client_factory.test.ts (81%) rename x-pack/legacy/plugins/alerting/server/{lib => }/alerts_client_factory.ts (92%) create mode 100644 x-pack/legacy/plugins/alerting/server/lib/result_type.ts delete mode 100644 x-pack/legacy/plugins/alerting/server/lib/task_runner_factory.test.ts delete mode 100644 x-pack/legacy/plugins/alerting/server/lib/task_runner_factory.ts rename x-pack/legacy/plugins/alerting/server/{lib => task_runner}/create_execution_handler.test.ts (100%) rename x-pack/legacy/plugins/alerting/server/{lib => task_runner}/create_execution_handler.ts (100%) rename x-pack/legacy/plugins/alerting/server/{lib => task_runner}/get_next_run_at.test.ts (100%) rename x-pack/legacy/plugins/alerting/server/{lib => task_runner}/get_next_run_at.ts (92%) create mode 100644 x-pack/legacy/plugins/alerting/server/task_runner/index.ts create mode 100644 x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts create mode 100644 x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts create mode 100644 x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.test.ts create mode 100644 x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.ts rename x-pack/legacy/plugins/alerting/server/{lib => task_runner}/transform_action_params.test.ts (100%) rename x-pack/legacy/plugins/alerting/server/{lib => task_runner}/transform_action_params.ts (100%) create mode 100644 x-pack/test/alerting_api_integration/common/lib/test_assertions.ts diff --git a/x-pack/legacy/plugins/alerting/server/lib/alert_instance.test.ts b/x-pack/legacy/plugins/alerting/server/alert_instance/alert_instance.test.ts similarity index 100% rename from x-pack/legacy/plugins/alerting/server/lib/alert_instance.test.ts rename to x-pack/legacy/plugins/alerting/server/alert_instance/alert_instance.test.ts diff --git a/x-pack/legacy/plugins/alerting/server/lib/alert_instance.ts b/x-pack/legacy/plugins/alerting/server/alert_instance/alert_instance.ts similarity index 97% rename from x-pack/legacy/plugins/alerting/server/lib/alert_instance.ts rename to x-pack/legacy/plugins/alerting/server/alert_instance/alert_instance.ts index 1e2cc26f364ad2..a56e2077cdfd83 100644 --- a/x-pack/legacy/plugins/alerting/server/lib/alert_instance.ts +++ b/x-pack/legacy/plugins/alerting/server/alert_instance/alert_instance.ts @@ -5,7 +5,7 @@ */ import { State, Context } from '../types'; -import { parseDuration } from './parse_duration'; +import { parseDuration } from '../lib'; interface Meta { lastScheduledActions?: { diff --git a/x-pack/legacy/plugins/alerting/server/lib/create_alert_instance_factory.test.ts b/x-pack/legacy/plugins/alerting/server/alert_instance/create_alert_instance_factory.test.ts similarity index 100% rename from x-pack/legacy/plugins/alerting/server/lib/create_alert_instance_factory.test.ts rename to x-pack/legacy/plugins/alerting/server/alert_instance/create_alert_instance_factory.test.ts diff --git a/x-pack/legacy/plugins/alerting/server/lib/create_alert_instance_factory.ts b/x-pack/legacy/plugins/alerting/server/alert_instance/create_alert_instance_factory.ts similarity index 100% rename from x-pack/legacy/plugins/alerting/server/lib/create_alert_instance_factory.ts rename to x-pack/legacy/plugins/alerting/server/alert_instance/create_alert_instance_factory.ts diff --git a/x-pack/legacy/plugins/alerting/server/alert_instance/index.ts b/x-pack/legacy/plugins/alerting/server/alert_instance/index.ts new file mode 100644 index 00000000000000..40ee0874e805cd --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/alert_instance/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { AlertInstance } from './alert_instance'; +export { createAlertInstanceFactory } from './create_alert_instance_factory'; diff --git a/x-pack/legacy/plugins/alerting/server/alert_type_registry.test.ts b/x-pack/legacy/plugins/alerting/server/alert_type_registry.test.ts index 57e1b965960e84..8e96ad8dae31cc 100644 --- a/x-pack/legacy/plugins/alerting/server/alert_type_registry.test.ts +++ b/x-pack/legacy/plugins/alerting/server/alert_type_registry.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { TaskRunnerFactory } from './lib'; +import { TaskRunnerFactory } from './task_runner'; import { AlertTypeRegistry } from './alert_type_registry'; import { taskManagerMock } from '../../task_manager/server/task_manager.mock'; diff --git a/x-pack/legacy/plugins/alerting/server/alert_type_registry.ts b/x-pack/legacy/plugins/alerting/server/alert_type_registry.ts index b7512864c2a981..2003e810a05b53 100644 --- a/x-pack/legacy/plugins/alerting/server/alert_type_registry.ts +++ b/x-pack/legacy/plugins/alerting/server/alert_type_registry.ts @@ -6,8 +6,8 @@ import Boom from 'boom'; import { i18n } from '@kbn/i18n'; -import { TaskRunnerFactory } from './lib'; -import { RunContext } from '../../task_manager/server'; +import { TaskRunnerFactory } from './task_runner'; +import { RunContext } from '../../task_manager'; import { TaskManagerSetupContract } from './shim'; import { AlertType } from './types'; diff --git a/x-pack/legacy/plugins/alerting/server/lib/alerts_client_factory.test.ts b/x-pack/legacy/plugins/alerting/server/alerts_client_factory.test.ts similarity index 81% rename from x-pack/legacy/plugins/alerting/server/lib/alerts_client_factory.test.ts rename to x-pack/legacy/plugins/alerting/server/alerts_client_factory.test.ts index 838c567fb28787..519001d07e089c 100644 --- a/x-pack/legacy/plugins/alerting/server/lib/alerts_client_factory.test.ts +++ b/x-pack/legacy/plugins/alerting/server/alerts_client_factory.test.ts @@ -6,13 +6,13 @@ import { Request } from 'hapi'; import { AlertsClientFactory, ConstructorOpts } from './alerts_client_factory'; -import { alertTypeRegistryMock } from '../alert_type_registry.mock'; -import { taskManagerMock } from '../../../task_manager/server/task_manager.mock'; -import { KibanaRequest } from '../../../../../../src/core/server'; -import { loggingServiceMock } from '../../../../../../src/core/server/mocks'; -import { encryptedSavedObjectsMock } from '../../../../../plugins/encrypted_saved_objects/server/mocks'; +import { alertTypeRegistryMock } from './alert_type_registry.mock'; +import { taskManagerMock } from '../../task_manager/server/task_manager.mock'; +import { KibanaRequest } from '../../../../../src/core/server'; +import { loggingServiceMock } from '../../../../../src/core/server/mocks'; +import { encryptedSavedObjectsMock } from '../../../../plugins/encrypted_saved_objects/server/mocks'; -jest.mock('../alerts_client'); +jest.mock('./alerts_client'); const savedObjectsClient = jest.fn(); const securityPluginSetup = { @@ -55,7 +55,7 @@ test('creates an alerts client with proper constructor arguments', async () => { const factory = new AlertsClientFactory(alertsClientFactoryParams); factory.create(KibanaRequest.from(fakeRequest), fakeRequest); - expect(jest.requireMock('../alerts_client').AlertsClient).toHaveBeenCalledWith({ + expect(jest.requireMock('./alerts_client').AlertsClient).toHaveBeenCalledWith({ savedObjectsClient, logger: alertsClientFactoryParams.logger, taskManager: alertsClientFactoryParams.taskManager, @@ -72,7 +72,7 @@ test('creates an alerts client with proper constructor arguments', async () => { test('getUserName() returns null when security is disabled', async () => { const factory = new AlertsClientFactory(alertsClientFactoryParams); factory.create(KibanaRequest.from(fakeRequest), fakeRequest); - const constructorCall = jest.requireMock('../alerts_client').AlertsClient.mock.calls[0][0]; + const constructorCall = jest.requireMock('./alerts_client').AlertsClient.mock.calls[0][0]; const userNameResult = await constructorCall.getUserName(); expect(userNameResult).toEqual(null); @@ -84,7 +84,7 @@ test('getUserName() returns a name when security is enabled', async () => { securityPluginSetup: securityPluginSetup as any, }); factory.create(KibanaRequest.from(fakeRequest), fakeRequest); - const constructorCall = jest.requireMock('../alerts_client').AlertsClient.mock.calls[0][0]; + const constructorCall = jest.requireMock('./alerts_client').AlertsClient.mock.calls[0][0]; securityPluginSetup.authc.getCurrentUser.mockResolvedValueOnce({ username: 'bob' }); const userNameResult = await constructorCall.getUserName(); @@ -94,7 +94,7 @@ test('getUserName() returns a name when security is enabled', async () => { test('createAPIKey() returns { apiKeysEnabled: false } when security is disabled', async () => { const factory = new AlertsClientFactory(alertsClientFactoryParams); factory.create(KibanaRequest.from(fakeRequest), fakeRequest); - const constructorCall = jest.requireMock('../alerts_client').AlertsClient.mock.calls[0][0]; + const constructorCall = jest.requireMock('./alerts_client').AlertsClient.mock.calls[0][0]; const createAPIKeyResult = await constructorCall.createAPIKey(); expect(createAPIKeyResult).toEqual({ apiKeysEnabled: false }); @@ -103,7 +103,7 @@ test('createAPIKey() returns { apiKeysEnabled: false } when security is disabled test('createAPIKey() returns { apiKeysEnabled: false } when security is enabled but ES security is disabled', async () => { const factory = new AlertsClientFactory(alertsClientFactoryParams); factory.create(KibanaRequest.from(fakeRequest), fakeRequest); - const constructorCall = jest.requireMock('../alerts_client').AlertsClient.mock.calls[0][0]; + const constructorCall = jest.requireMock('./alerts_client').AlertsClient.mock.calls[0][0]; securityPluginSetup.authc.createAPIKey.mockResolvedValueOnce(null); const createAPIKeyResult = await constructorCall.createAPIKey(); @@ -116,7 +116,7 @@ test('createAPIKey() returns an API key when security is enabled', async () => { securityPluginSetup: securityPluginSetup as any, }); factory.create(KibanaRequest.from(fakeRequest), fakeRequest); - const constructorCall = jest.requireMock('../alerts_client').AlertsClient.mock.calls[0][0]; + const constructorCall = jest.requireMock('./alerts_client').AlertsClient.mock.calls[0][0]; securityPluginSetup.authc.createAPIKey.mockResolvedValueOnce({ api_key: '123', id: 'abc' }); const createAPIKeyResult = await constructorCall.createAPIKey(); @@ -132,7 +132,7 @@ test('createAPIKey() throws when security plugin createAPIKey throws an error', securityPluginSetup: securityPluginSetup as any, }); factory.create(KibanaRequest.from(fakeRequest), fakeRequest); - const constructorCall = jest.requireMock('../alerts_client').AlertsClient.mock.calls[0][0]; + const constructorCall = jest.requireMock('./alerts_client').AlertsClient.mock.calls[0][0]; securityPluginSetup.authc.createAPIKey.mockRejectedValueOnce(new Error('TLS disabled')); await expect(constructorCall.createAPIKey()).rejects.toThrowErrorMatchingInlineSnapshot( diff --git a/x-pack/legacy/plugins/alerting/server/lib/alerts_client_factory.ts b/x-pack/legacy/plugins/alerting/server/alerts_client_factory.ts similarity index 92% rename from x-pack/legacy/plugins/alerting/server/lib/alerts_client_factory.ts rename to x-pack/legacy/plugins/alerting/server/alerts_client_factory.ts index 026d6c92b0d75d..94a396fbaa8063 100644 --- a/x-pack/legacy/plugins/alerting/server/lib/alerts_client_factory.ts +++ b/x-pack/legacy/plugins/alerting/server/alerts_client_factory.ts @@ -6,12 +6,12 @@ import Hapi from 'hapi'; import uuid from 'uuid'; -import { AlertsClient } from '../alerts_client'; -import { AlertTypeRegistry, SpaceIdToNamespaceFunction } from '../types'; -import { SecurityPluginStartContract, TaskManagerStartContract } from '../shim'; -import { KibanaRequest, Logger } from '../../../../../../src/core/server'; -import { InvalidateAPIKeyParams } from '../../../../../plugins/security/server'; -import { PluginStartContract as EncryptedSavedObjectsStartContract } from '../../../../../plugins/encrypted_saved_objects/server'; +import { AlertsClient } from './alerts_client'; +import { AlertTypeRegistry, SpaceIdToNamespaceFunction } from './types'; +import { SecurityPluginStartContract, TaskManagerStartContract } from './shim'; +import { KibanaRequest, Logger } from '../../../../../src/core/server'; +import { InvalidateAPIKeyParams } from '../../../../plugins/security/server'; +import { PluginStartContract as EncryptedSavedObjectsStartContract } from '../../../../plugins/encrypted_saved_objects/server'; export interface ConstructorOpts { logger: Logger; diff --git a/x-pack/legacy/plugins/alerting/server/lib/index.ts b/x-pack/legacy/plugins/alerting/server/lib/index.ts index ca4ddf9e11ad28..c41ea4a5998ff4 100644 --- a/x-pack/legacy/plugins/alerting/server/lib/index.ts +++ b/x-pack/legacy/plugins/alerting/server/lib/index.ts @@ -4,8 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export { AlertInstance } from './alert_instance'; -export { validateAlertTypeParams } from './validate_alert_type_params'; export { parseDuration, getDurationSchema } from './parse_duration'; -export { AlertsClientFactory } from './alerts_client_factory'; -export { TaskRunnerFactory } from './task_runner_factory'; +export { LicenseState } from './license_state'; +export { validateAlertTypeParams } from './validate_alert_type_params'; diff --git a/x-pack/legacy/plugins/alerting/server/lib/result_type.ts b/x-pack/legacy/plugins/alerting/server/lib/result_type.ts new file mode 100644 index 00000000000000..644ae51292249d --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/lib/result_type.ts @@ -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. + */ + +export interface Ok { + tag: 'ok'; + value: T; +} + +export interface Err { + tag: 'err'; + error: E; +} +export type Result = Ok | Err; + +export function asOk(value: T): Ok { + return { + tag: 'ok', + value, + }; +} + +export function asErr(error: T): Err { + return { + tag: 'err', + error, + }; +} + +export function isOk(result: Result): result is Ok { + return result.tag === 'ok'; +} + +export function isErr(result: Result): result is Err { + return !isOk(result); +} + +export async function promiseResult(future: Promise): Promise> { + try { + return asOk(await future); + } catch (e) { + return asErr(e); + } +} + +export function map( + result: Result, + onOk: (value: T) => Resolution, + onErr: (error: E) => Resolution +): Resolution { + return isOk(result) ? onOk(result.value) : onErr(result.error); +} diff --git a/x-pack/legacy/plugins/alerting/server/lib/task_runner_factory.test.ts b/x-pack/legacy/plugins/alerting/server/lib/task_runner_factory.test.ts deleted file mode 100644 index fd13452e045353..00000000000000 --- a/x-pack/legacy/plugins/alerting/server/lib/task_runner_factory.test.ts +++ /dev/null @@ -1,345 +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. - */ - -import sinon from 'sinon'; -import { schema } from '@kbn/config-schema'; -import { AlertExecutorOptions } from '../types'; -import { ConcreteTaskInstance, TaskStatus } from '../../../task_manager/server'; -import { TaskRunnerContext, TaskRunnerFactory } from './task_runner_factory'; -import { encryptedSavedObjectsMock } from '../../../../../plugins/encrypted_saved_objects/server/mocks'; -import { - savedObjectsClientMock, - loggingServiceMock, -} from '../../../../../../src/core/server/mocks'; - -const alertType = { - id: 'test', - name: 'My test alert', - actionGroups: ['default'], - executor: jest.fn(), -}; -let fakeTimer: sinon.SinonFakeTimers; -let taskRunnerFactory: TaskRunnerFactory; -let mockedTaskInstance: ConcreteTaskInstance; - -beforeAll(() => { - fakeTimer = sinon.useFakeTimers(); - mockedTaskInstance = { - id: '', - attempts: 0, - status: TaskStatus.Running, - version: '123', - runAt: new Date(), - scheduledAt: new Date(), - startedAt: new Date(), - retryAt: new Date(Date.now() + 5 * 60 * 1000), - state: { - startedAt: new Date(Date.now() - 5 * 60 * 1000), - }, - taskType: 'alerting:test', - params: { - alertId: '1', - }, - ownerId: null, - }; - taskRunnerFactory = new TaskRunnerFactory(); - taskRunnerFactory.initialize(taskRunnerFactoryInitializerParams); -}); - -afterAll(() => fakeTimer.restore()); - -const savedObjectsClient = savedObjectsClientMock.create(); -const encryptedSavedObjectsPlugin = encryptedSavedObjectsMock.createStart(); -const services = { - log: jest.fn(), - callCluster: jest.fn(), - savedObjectsClient, -}; - -const taskRunnerFactoryInitializerParams: jest.Mocked = { - getServices: jest.fn().mockReturnValue(services), - executeAction: jest.fn(), - encryptedSavedObjectsPlugin, - logger: loggingServiceMock.create().get(), - spaceIdToNamespace: jest.fn().mockReturnValue(undefined), - getBasePath: jest.fn().mockReturnValue(undefined), -}; - -const mockedAlertTypeSavedObject = { - id: '1', - type: 'alert', - attributes: { - enabled: true, - alertTypeId: '123', - schedule: { interval: '10s' }, - mutedInstanceIds: [], - params: { - bar: true, - }, - actions: [ - { - group: 'default', - actionRef: 'action_0', - params: { - foo: true, - }, - }, - ], - }, - references: [ - { - name: 'action_0', - type: 'action', - id: '1', - }, - ], -}; - -beforeEach(() => { - jest.resetAllMocks(); - taskRunnerFactoryInitializerParams.getServices.mockReturnValue(services); -}); - -test(`throws an error if factory isn't initialized`, () => { - const factory = new TaskRunnerFactory(); - expect(() => - factory.create(alertType, { taskInstance: mockedTaskInstance }) - ).toThrowErrorMatchingInlineSnapshot(`"TaskRunnerFactory not initialized"`); -}); - -test(`throws an error if factory is already initialized`, () => { - const factory = new TaskRunnerFactory(); - factory.initialize(taskRunnerFactoryInitializerParams); - expect(() => - factory.initialize(taskRunnerFactoryInitializerParams) - ).toThrowErrorMatchingInlineSnapshot(`"TaskRunnerFactory already initialized"`); -}); - -test('successfully executes the task', async () => { - const taskRunner = taskRunnerFactory.create(alertType, { - taskInstance: mockedTaskInstance, - }); - savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); - encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ - id: '1', - type: 'alert', - attributes: { - apiKey: Buffer.from('123:abc').toString('base64'), - }, - references: [], - }); - const runnerResult = await taskRunner.run(); - expect(runnerResult).toMatchInlineSnapshot(` - Object { - "runAt": 1970-01-01T00:00:10.000Z, - "state": Object { - "alertInstances": Object {}, - "alertTypeState": undefined, - "previousStartedAt": 1970-01-01T00:00:00.000Z, - }, - } - `); - expect(alertType.executor).toHaveBeenCalledTimes(1); - const call = alertType.executor.mock.calls[0][0]; - expect(call.params).toMatchInlineSnapshot(` - Object { - "bar": true, - } - `); - expect(call.startedAt).toMatchInlineSnapshot(`1970-01-01T00:00:00.000Z`); - expect(call.state).toMatchInlineSnapshot(`Object {}`); - expect(call.services.alertInstanceFactory).toBeTruthy(); - expect(call.services.callCluster).toBeTruthy(); - expect(call.services).toBeTruthy(); -}); - -test('executeAction is called per alert instance that is scheduled', async () => { - alertType.executor.mockImplementation(({ services: executorServices }: AlertExecutorOptions) => { - executorServices.alertInstanceFactory('1').scheduleActions('default'); - }); - const taskRunner = taskRunnerFactory.create(alertType, { - taskInstance: mockedTaskInstance, - }); - savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); - encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ - id: '1', - type: 'alert', - attributes: { - apiKey: Buffer.from('123:abc').toString('base64'), - }, - references: [], - }); - await taskRunner.run(); - expect(taskRunnerFactoryInitializerParams.executeAction).toHaveBeenCalledTimes(1); - expect(taskRunnerFactoryInitializerParams.executeAction.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "apiKey": "MTIzOmFiYw==", - "id": "1", - "params": Object { - "foo": true, - }, - "spaceId": undefined, - }, - ] - `); -}); - -test('persists alertInstances passed in from state, only if they are scheduled for execution', async () => { - alertType.executor.mockImplementation(({ services: executorServices }: AlertExecutorOptions) => { - executorServices.alertInstanceFactory('1').scheduleActions('default'); - }); - const taskRunner = taskRunnerFactory.create(alertType, { - taskInstance: { - ...mockedTaskInstance, - state: { - ...mockedTaskInstance.state, - alertInstances: { - '1': { meta: {}, state: { bar: false } }, - '2': { meta: {}, state: { bar: false } }, - }, - }, - }, - }); - savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); - encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ - id: '1', - type: 'alert', - attributes: { - apiKey: Buffer.from('123:abc').toString('base64'), - }, - references: [], - }); - const runnerResult = await taskRunner.run(); - expect(runnerResult.state.alertInstances).toMatchInlineSnapshot(` - Object { - "1": Object { - "meta": Object { - "lastScheduledActions": Object { - "date": 1970-01-01T00:00:00.000Z, - "group": "default", - }, - }, - "state": Object { - "bar": false, - }, - }, - } - `); -}); - -test('validates params before executing the alert type', async () => { - const taskRunner = taskRunnerFactory.create( - { - ...alertType, - validate: { - params: schema.object({ - param1: schema.string(), - }), - }, - }, - { taskInstance: mockedTaskInstance } - ); - savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); - encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ - id: '1', - type: 'alert', - attributes: { - apiKey: Buffer.from('123:abc').toString('base64'), - }, - references: [], - }); - await expect(taskRunner.run()).rejects.toThrowErrorMatchingInlineSnapshot( - `"params invalid: [param1]: expected value of type [string] but got [undefined]"` - ); -}); - -test('throws error if reference not found', async () => { - const taskRunner = taskRunnerFactory.create(alertType, { - taskInstance: mockedTaskInstance, - }); - savedObjectsClient.get.mockResolvedValueOnce({ - ...mockedAlertTypeSavedObject, - references: [], - }); - encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ - id: '1', - type: 'alert', - attributes: { - apiKey: Buffer.from('123:abc').toString('base64'), - }, - references: [], - }); - await expect(taskRunner.run()).rejects.toThrowErrorMatchingInlineSnapshot( - `"Action reference \\"action_0\\" not found in alert id: 1"` - ); -}); - -test('uses API key when provided', async () => { - const taskRunner = taskRunnerFactory.create(alertType, { - taskInstance: mockedTaskInstance, - }); - savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); - encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ - id: '1', - type: 'alert', - attributes: { - apiKey: Buffer.from('123:abc').toString('base64'), - }, - references: [], - }); - - await taskRunner.run(); - expect(taskRunnerFactoryInitializerParams.getServices).toHaveBeenCalledWith({ - getBasePath: expect.anything(), - headers: { - // base64 encoded "123:abc" - authorization: 'ApiKey MTIzOmFiYw==', - }, - path: '/', - route: { settings: {} }, - url: { - href: '/', - }, - raw: { - req: { - url: '/', - }, - }, - }); -}); - -test(`doesn't use API key when not provided`, async () => { - const factory = new TaskRunnerFactory(); - factory.initialize(taskRunnerFactoryInitializerParams); - const taskRunner = factory.create(alertType, { - taskInstance: mockedTaskInstance, - }); - savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); - encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ - id: '1', - type: 'alert', - attributes: {}, - references: [], - }); - - await taskRunner.run(); - - expect(taskRunnerFactoryInitializerParams.getServices).toHaveBeenCalledWith({ - getBasePath: expect.anything(), - headers: {}, - path: '/', - route: { settings: {} }, - url: { - href: '/', - }, - raw: { - req: { - url: '/', - }, - }, - }); -}); diff --git a/x-pack/legacy/plugins/alerting/server/lib/task_runner_factory.ts b/x-pack/legacy/plugins/alerting/server/lib/task_runner_factory.ts deleted file mode 100644 index 5614188795ded7..00000000000000 --- a/x-pack/legacy/plugins/alerting/server/lib/task_runner_factory.ts +++ /dev/null @@ -1,190 +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. - */ - -import { Logger } from '../../../../../../src/core/server'; -import { RunContext } from '../../../task_manager/server'; -import { createExecutionHandler } from './create_execution_handler'; -import { createAlertInstanceFactory } from './create_alert_instance_factory'; -import { AlertInstance } from './alert_instance'; -import { getNextRunAt } from './get_next_run_at'; -import { validateAlertTypeParams } from './validate_alert_type_params'; -import { PluginStartContract as EncryptedSavedObjectsStartContract } from '../../../../../plugins/encrypted_saved_objects/server'; -import { PluginStartContract as ActionsPluginStartContract } from '../../../actions'; -import { - AlertType, - AlertServices, - GetBasePathFunction, - GetServicesFunction, - RawAlert, - SpaceIdToNamespaceFunction, - IntervalSchedule, -} from '../types'; - -export interface TaskRunnerContext { - logger: Logger; - getServices: GetServicesFunction; - executeAction: ActionsPluginStartContract['execute']; - encryptedSavedObjectsPlugin: EncryptedSavedObjectsStartContract; - spaceIdToNamespace: SpaceIdToNamespaceFunction; - getBasePath: GetBasePathFunction; -} - -export class TaskRunnerFactory { - private isInitialized = false; - private taskRunnerContext?: TaskRunnerContext; - - public initialize(taskRunnerContext: TaskRunnerContext) { - if (this.isInitialized) { - throw new Error('TaskRunnerFactory already initialized'); - } - this.isInitialized = true; - this.taskRunnerContext = taskRunnerContext; - } - - public create(alertType: AlertType, { taskInstance }: RunContext) { - if (!this.isInitialized) { - throw new Error('TaskRunnerFactory not initialized'); - } - - const { - logger, - getServices, - executeAction, - encryptedSavedObjectsPlugin, - spaceIdToNamespace, - getBasePath, - } = this.taskRunnerContext!; - - return { - async run() { - const { alertId, spaceId } = taskInstance.params; - const requestHeaders: Record = {}; - const namespace = spaceIdToNamespace(spaceId); - // Only fetch encrypted attributes here, we'll create a saved objects client - // scoped with the API key to fetch the remaining data. - const { - attributes: { apiKey }, - } = await encryptedSavedObjectsPlugin.getDecryptedAsInternalUser( - 'alert', - alertId, - { namespace } - ); - - if (apiKey) { - requestHeaders.authorization = `ApiKey ${apiKey}`; - } - - const fakeRequest = { - headers: requestHeaders, - getBasePath: () => getBasePath(spaceId), - path: '/', - route: { settings: {} }, - url: { - href: '/', - }, - raw: { - req: { - url: '/', - }, - }, - }; - - const services = getServices(fakeRequest); - // Ensure API key is still valid and user has access - const { - attributes: { params, actions, schedule, throttle, muteAll, mutedInstanceIds }, - references, - } = await services.savedObjectsClient.get('alert', alertId); - - // Validate - const validatedAlertTypeParams = validateAlertTypeParams(alertType, params); - - // Inject ids into actions - const actionsWithIds = actions.map(action => { - const actionReference = references.find(obj => obj.name === action.actionRef); - if (!actionReference) { - throw new Error( - `Action reference "${action.actionRef}" not found in alert id: ${alertId}` - ); - } - return { - ...action, - id: actionReference.id, - }; - }); - - const executionHandler = createExecutionHandler({ - alertId, - logger, - executeAction, - apiKey, - actions: actionsWithIds, - spaceId, - alertType, - }); - const alertInstances: Record = {}; - const alertInstancesData = taskInstance.state.alertInstances || {}; - for (const id of Object.keys(alertInstancesData)) { - alertInstances[id] = new AlertInstance(alertInstancesData[id]); - } - const alertInstanceFactory = createAlertInstanceFactory(alertInstances); - - const alertTypeServices: AlertServices = { - ...services, - alertInstanceFactory, - }; - - const alertTypeState = await alertType.executor({ - alertId, - services: alertTypeServices, - params: validatedAlertTypeParams, - state: taskInstance.state.alertTypeState || {}, - startedAt: taskInstance.startedAt!, - previousStartedAt: taskInstance.state.previousStartedAt, - }); - - await Promise.all( - Object.keys(alertInstances).map(alertInstanceId => { - const alertInstance = alertInstances[alertInstanceId]; - if (alertInstance.hasScheduledActions()) { - if ( - alertInstance.isThrottled(throttle) || - muteAll || - mutedInstanceIds.includes(alertInstanceId) - ) { - return; - } - const { actionGroup, context, state } = alertInstance.getScheduledActionOptions()!; - alertInstance.updateLastScheduledActions(actionGroup); - alertInstance.unscheduleActions(); - return executionHandler({ actionGroup, context, state, alertInstanceId }); - } else { - // Cleanup alert instances that are no longer scheduling actions to avoid over populating the alertInstances object - delete alertInstances[alertInstanceId]; - } - }) - ); - - const nextRunAt = getNextRunAt( - new Date(taskInstance.startedAt!), - // we do not currently have a good way of returning the type - // from SavedObjectsClient, and as we currenrtly require a schedule - // and we only support `interval`, we can cast this safely - schedule as IntervalSchedule - ); - - return { - state: { - alertTypeState, - alertInstances, - previousStartedAt: taskInstance.startedAt!, - }, - runAt: nextRunAt, - }; - }, - }; - } -} diff --git a/x-pack/legacy/plugins/alerting/server/plugin.ts b/x-pack/legacy/plugins/alerting/server/plugin.ts index 24d4467dbd8073..ede95f76bf8113 100644 --- a/x-pack/legacy/plugins/alerting/server/plugin.ts +++ b/x-pack/legacy/plugins/alerting/server/plugin.ts @@ -9,7 +9,8 @@ import { first } from 'rxjs/operators'; import { Services } from './types'; import { AlertsClient } from './alerts_client'; import { AlertTypeRegistry } from './alert_type_registry'; -import { AlertsClientFactory, TaskRunnerFactory } from './lib'; +import { TaskRunnerFactory } from './task_runner'; +import { AlertsClientFactory } from './alerts_client_factory'; import { LicenseState } from './lib/license_state'; import { IClusterClient, KibanaRequest, Logger } from '../../../../../src/core/server'; import { diff --git a/x-pack/legacy/plugins/alerting/server/lib/create_execution_handler.test.ts b/x-pack/legacy/plugins/alerting/server/task_runner/create_execution_handler.test.ts similarity index 100% rename from x-pack/legacy/plugins/alerting/server/lib/create_execution_handler.test.ts rename to x-pack/legacy/plugins/alerting/server/task_runner/create_execution_handler.test.ts diff --git a/x-pack/legacy/plugins/alerting/server/lib/create_execution_handler.ts b/x-pack/legacy/plugins/alerting/server/task_runner/create_execution_handler.ts similarity index 100% rename from x-pack/legacy/plugins/alerting/server/lib/create_execution_handler.ts rename to x-pack/legacy/plugins/alerting/server/task_runner/create_execution_handler.ts diff --git a/x-pack/legacy/plugins/alerting/server/lib/get_next_run_at.test.ts b/x-pack/legacy/plugins/alerting/server/task_runner/get_next_run_at.test.ts similarity index 100% rename from x-pack/legacy/plugins/alerting/server/lib/get_next_run_at.test.ts rename to x-pack/legacy/plugins/alerting/server/task_runner/get_next_run_at.test.ts diff --git a/x-pack/legacy/plugins/alerting/server/lib/get_next_run_at.ts b/x-pack/legacy/plugins/alerting/server/task_runner/get_next_run_at.ts similarity index 92% rename from x-pack/legacy/plugins/alerting/server/lib/get_next_run_at.ts rename to x-pack/legacy/plugins/alerting/server/task_runner/get_next_run_at.ts index f9867b53729080..cea4584e1f713b 100644 --- a/x-pack/legacy/plugins/alerting/server/lib/get_next_run_at.ts +++ b/x-pack/legacy/plugins/alerting/server/task_runner/get_next_run_at.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { parseDuration } from './parse_duration'; +import { parseDuration } from '../lib'; import { IntervalSchedule } from '../types'; export function getNextRunAt(currentRunAt: Date, schedule: IntervalSchedule) { diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/index.ts b/x-pack/legacy/plugins/alerting/server/task_runner/index.ts new file mode 100644 index 00000000000000..f5401fbd9cd746 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/task_runner/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 { TaskRunnerFactory } from './task_runner_factory'; diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts new file mode 100644 index 00000000000000..10627c655eca86 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts @@ -0,0 +1,400 @@ +/* + * 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 sinon from 'sinon'; +import { schema } from '@kbn/config-schema'; +import { AlertExecutorOptions } from '../types'; +import { ConcreteTaskInstance, TaskStatus } from '../../../task_manager'; +import { TaskRunnerContext } from './task_runner_factory'; +import { TaskRunner } from './task_runner'; +import { encryptedSavedObjectsMock } from '../../../../../plugins/encrypted_saved_objects/server/mocks'; +import { + savedObjectsClientMock, + loggingServiceMock, +} from '../../../../../../src/core/server/mocks'; + +const alertType = { + id: 'test', + name: 'My test alert', + actionGroups: ['default'], + executor: jest.fn(), +}; +let fakeTimer: sinon.SinonFakeTimers; + +describe('Task Runner', () => { + let mockedTaskInstance: ConcreteTaskInstance; + + beforeAll(() => { + fakeTimer = sinon.useFakeTimers(); + mockedTaskInstance = { + id: '', + attempts: 0, + status: TaskStatus.Running, + version: '123', + runAt: new Date(), + scheduledAt: new Date(), + startedAt: new Date(), + retryAt: new Date(Date.now() + 5 * 60 * 1000), + state: { + startedAt: new Date(Date.now() - 5 * 60 * 1000), + }, + taskType: 'alerting:test', + params: { + alertId: '1', + }, + ownerId: null, + }; + }); + + afterAll(() => fakeTimer.restore()); + + const savedObjectsClient = savedObjectsClientMock.create(); + const encryptedSavedObjectsPlugin = encryptedSavedObjectsMock.createStart(); + const services = { + log: jest.fn(), + callCluster: jest.fn(), + savedObjectsClient, + }; + + const taskRunnerFactoryInitializerParams: jest.Mocked = { + getServices: jest.fn().mockReturnValue(services), + executeAction: jest.fn(), + encryptedSavedObjectsPlugin, + logger: loggingServiceMock.create().get(), + spaceIdToNamespace: jest.fn().mockReturnValue(undefined), + getBasePath: jest.fn().mockReturnValue(undefined), + }; + + const mockedAlertTypeSavedObject = { + id: '1', + type: 'alert', + attributes: { + enabled: true, + alertTypeId: '123', + schedule: { interval: '10s' }, + mutedInstanceIds: [], + params: { + bar: true, + }, + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + }, + }, + ], + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }; + + beforeEach(() => { + jest.resetAllMocks(); + taskRunnerFactoryInitializerParams.getServices.mockReturnValue(services); + }); + + test('successfully executes the task', async () => { + const taskRunner = new TaskRunner( + alertType, + mockedTaskInstance, + taskRunnerFactoryInitializerParams + ); + savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + const runnerResult = await taskRunner.run(); + expect(runnerResult).toMatchInlineSnapshot(` + Object { + "runAt": 1970-01-01T00:00:10.000Z, + "state": Object { + "alertInstances": Object {}, + "alertTypeState": undefined, + "previousStartedAt": 1970-01-01T00:00:00.000Z, + }, + } + `); + expect(alertType.executor).toHaveBeenCalledTimes(1); + const call = alertType.executor.mock.calls[0][0]; + expect(call.params).toMatchInlineSnapshot(` + Object { + "bar": true, + } + `); + expect(call.startedAt).toMatchInlineSnapshot(`1970-01-01T00:00:00.000Z`); + expect(call.state).toMatchInlineSnapshot(`Object {}`); + expect(call.services.alertInstanceFactory).toBeTruthy(); + expect(call.services.callCluster).toBeTruthy(); + expect(call.services).toBeTruthy(); + }); + + test('executeAction is called per alert instance that is scheduled', async () => { + alertType.executor.mockImplementation( + ({ services: executorServices }: AlertExecutorOptions) => { + executorServices.alertInstanceFactory('1').scheduleActions('default'); + } + ); + const taskRunner = new TaskRunner( + alertType, + mockedTaskInstance, + taskRunnerFactoryInitializerParams + ); + savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + await taskRunner.run(); + expect(taskRunnerFactoryInitializerParams.executeAction).toHaveBeenCalledTimes(1); + expect(taskRunnerFactoryInitializerParams.executeAction.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "apiKey": "MTIzOmFiYw==", + "id": "1", + "params": Object { + "foo": true, + }, + "spaceId": undefined, + }, + ] + `); + }); + + test('persists alertInstances passed in from state, only if they are scheduled for execution', async () => { + alertType.executor.mockImplementation( + ({ services: executorServices }: AlertExecutorOptions) => { + executorServices.alertInstanceFactory('1').scheduleActions('default'); + } + ); + const taskRunner = new TaskRunner( + alertType, + { + ...mockedTaskInstance, + state: { + ...mockedTaskInstance.state, + alertInstances: { + '1': { meta: {}, state: { bar: false } }, + '2': { meta: {}, state: { bar: false } }, + }, + }, + }, + taskRunnerFactoryInitializerParams + ); + savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + const runnerResult = await taskRunner.run(); + expect(runnerResult.state.alertInstances).toMatchInlineSnapshot(` + Object { + "1": Object { + "meta": Object { + "lastScheduledActions": Object { + "date": 1970-01-01T00:00:00.000Z, + "group": "default", + }, + }, + "state": Object { + "bar": false, + }, + }, + } + `); + }); + + test('validates params before executing the alert type', async () => { + const taskRunner = new TaskRunner( + { + ...alertType, + validate: { + params: schema.object({ + param1: schema.string(), + }), + }, + }, + mockedTaskInstance, + taskRunnerFactoryInitializerParams + ); + savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + expect(await taskRunner.run()).toMatchInlineSnapshot(` + Object { + "runAt": 1970-01-01T00:00:10.000Z, + "state": Object { + "previousStartedAt": 1970-01-01T00:00:00.000Z, + "startedAt": 1969-12-31T23:55:00.000Z, + }, + } + `); + expect(taskRunnerFactoryInitializerParams.logger.error).toHaveBeenCalledWith( + `Executing Alert \"1\" has resulted in Error: params invalid: [param1]: expected value of type [string] but got [undefined]` + ); + }); + + test('throws error if reference not found', async () => { + const taskRunner = new TaskRunner( + alertType, + mockedTaskInstance, + taskRunnerFactoryInitializerParams + ); + savedObjectsClient.get.mockResolvedValueOnce({ + ...mockedAlertTypeSavedObject, + references: [], + }); + encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + expect(await taskRunner.run()).toMatchInlineSnapshot(` + Object { + "runAt": 1970-01-01T00:00:10.000Z, + "state": Object { + "previousStartedAt": 1970-01-01T00:00:00.000Z, + "startedAt": 1969-12-31T23:55:00.000Z, + }, + } + `); + expect(taskRunnerFactoryInitializerParams.logger.error).toHaveBeenCalledWith( + `Executing Alert \"1\" has resulted in Error: Action reference \"action_0\" not found in alert id: 1` + ); + }); + + test('uses API key when provided', async () => { + const taskRunner = new TaskRunner( + alertType, + mockedTaskInstance, + taskRunnerFactoryInitializerParams + ); + savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + + await taskRunner.run(); + expect(taskRunnerFactoryInitializerParams.getServices).toHaveBeenCalledWith({ + getBasePath: expect.anything(), + headers: { + // base64 encoded "123:abc" + authorization: 'ApiKey MTIzOmFiYw==', + }, + path: '/', + route: { settings: {} }, + url: { + href: '/', + }, + raw: { + req: { + url: '/', + }, + }, + }); + }); + + test(`doesn't use API key when not provided`, async () => { + const taskRunner = new TaskRunner( + alertType, + mockedTaskInstance, + taskRunnerFactoryInitializerParams + ); + savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: {}, + references: [], + }); + + await taskRunner.run(); + + expect(taskRunnerFactoryInitializerParams.getServices).toHaveBeenCalledWith({ + getBasePath: expect.anything(), + headers: {}, + path: '/', + route: { settings: {} }, + url: { + href: '/', + }, + raw: { + req: { + url: '/', + }, + }, + }); + }); + + test('recovers gracefully when the AlertType executor throws an exception', async () => { + alertType.executor.mockImplementation( + ({ services: executorServices }: AlertExecutorOptions) => { + throw new Error('OMG'); + } + ); + + const taskRunner = new TaskRunner( + alertType, + mockedTaskInstance, + taskRunnerFactoryInitializerParams + ); + + savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + + const runnerResult = await taskRunner.run(); + + expect(runnerResult).toMatchInlineSnapshot(` + Object { + "runAt": 1970-01-01T00:00:10.000Z, + "state": Object { + "previousStartedAt": 1970-01-01T00:00:00.000Z, + "startedAt": 1969-12-31T23:55:00.000Z, + }, + } + `); + }); +}); diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts new file mode 100644 index 00000000000000..2347e9e608ed9f --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts @@ -0,0 +1,241 @@ +/* + * 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 { pick, mapValues, omit } from 'lodash'; +import { Logger } from '../../../../../../src/core/server'; +import { SavedObject } from '../../../../../../src/core/server'; +import { TaskRunnerContext } from './task_runner_factory'; +import { ConcreteTaskInstance } from '../../../task_manager'; +import { createExecutionHandler } from './create_execution_handler'; +import { AlertInstance, createAlertInstanceFactory } from '../alert_instance'; +import { getNextRunAt } from './get_next_run_at'; +import { validateAlertTypeParams } from '../lib'; +import { AlertType, RawAlert, IntervalSchedule, Services, State } from '../types'; +import { promiseResult, map } from '../lib/result_type'; + +type AlertInstances = Record; + +export class TaskRunner { + private context: TaskRunnerContext; + private logger: Logger; + private taskInstance: ConcreteTaskInstance; + private alertType: AlertType; + + constructor( + alertType: AlertType, + taskInstance: ConcreteTaskInstance, + context: TaskRunnerContext + ) { + this.context = context; + this.logger = context.logger; + this.alertType = alertType; + this.taskInstance = taskInstance; + } + + async getApiKeyForAlertPermissions(alertId: string, spaceId: string) { + const namespace = this.context.spaceIdToNamespace(spaceId); + // Only fetch encrypted attributes here, we'll create a saved objects client + // scoped with the API key to fetch the remaining data. + const { + attributes: { apiKey }, + } = await this.context.encryptedSavedObjectsPlugin.getDecryptedAsInternalUser( + 'alert', + alertId, + { namespace } + ); + + return apiKey; + } + + async getServicesWithSpaceLevelPermissions(spaceId: string, apiKey: string | null) { + const requestHeaders: Record = {}; + + if (apiKey) { + requestHeaders.authorization = `ApiKey ${apiKey}`; + } + + const fakeRequest = { + headers: requestHeaders, + getBasePath: () => this.context.getBasePath(spaceId), + path: '/', + route: { settings: {} }, + url: { + href: '/', + }, + raw: { + req: { + url: '/', + }, + }, + }; + + return this.context.getServices(fakeRequest); + } + + getExecutionHandler( + alertId: string, + spaceId: string, + apiKey: string | null, + actions: RawAlert['actions'], + references: SavedObject['references'] + ) { + // Inject ids into actions + const actionsWithIds = actions.map(action => { + const actionReference = references.find(obj => obj.name === action.actionRef); + if (!actionReference) { + throw new Error(`Action reference "${action.actionRef}" not found in alert id: ${alertId}`); + } + return { + ...action, + id: actionReference.id, + }; + }); + + return createExecutionHandler({ + alertId, + logger: this.logger, + executeAction: this.context.executeAction, + apiKey, + actions: actionsWithIds, + spaceId, + alertType: this.alertType, + }); + } + + async executeAlertInstance( + alertInstanceId: string, + alertInstance: AlertInstance, + executionHandler: ReturnType + ) { + const { actionGroup, context, state } = alertInstance.getScheduledActionOptions()!; + alertInstance.updateLastScheduledActions(actionGroup); + alertInstance.unscheduleActions(); + return executionHandler({ actionGroup, context, state, alertInstanceId }); + } + + async executeAlertInstances( + services: Services, + { params, throttle, muteAll, mutedInstanceIds }: SavedObject['attributes'], + executionHandler: ReturnType + ): Promise { + const { + params: { alertId }, + state: { alertInstances: alertRawInstances = {}, alertTypeState = {}, previousStartedAt }, + } = this.taskInstance; + + const alertInstances = mapValues( + alertRawInstances, + alert => new AlertInstance(alert) + ); + + const updatedAlertTypeState = await this.alertType.executor({ + alertId, + services: { + ...services, + alertInstanceFactory: createAlertInstanceFactory(alertInstances), + }, + params, + state: alertTypeState, + startedAt: this.taskInstance.startedAt!, + previousStartedAt, + }); + + // Cleanup alert instances that are no longer scheduling actions to avoid over populating the alertInstances object + const instancesWithScheduledActions = pick( + alertInstances, + alertInstance => alertInstance.hasScheduledActions() + ); + + if (!muteAll) { + const enabledAlertInstances = omit( + instancesWithScheduledActions, + ...mutedInstanceIds + ); + + await Promise.all( + Object.entries(enabledAlertInstances) + .filter( + ([, alertInstance]: [string, AlertInstance]) => !alertInstance.isThrottled(throttle) + ) + .map(([id, alertInstance]: [string, AlertInstance]) => + this.executeAlertInstance(id, alertInstance, executionHandler) + ) + ); + } + + return { + alertTypeState: updatedAlertTypeState, + alertInstances: instancesWithScheduledActions, + }; + } + + async validateAndRunAlert( + services: Services, + apiKey: string | null, + attributes: SavedObject['attributes'], + references: SavedObject['references'] + ) { + const { + params: { alertId, spaceId }, + } = this.taskInstance; + + // Validate + const params = validateAlertTypeParams(this.alertType, attributes.params); + const executionHandler = this.getExecutionHandler( + alertId, + spaceId, + apiKey, + attributes.actions, + references + ); + return this.executeAlertInstances(services, { ...attributes, params }, executionHandler); + } + + async run() { + const { + params: { alertId, spaceId }, + startedAt: previousStartedAt, + state: originalState, + } = this.taskInstance; + + const apiKey = await this.getApiKeyForAlertPermissions(alertId, spaceId); + const services = await this.getServicesWithSpaceLevelPermissions(spaceId, apiKey); + + // Ensure API key is still valid and user has access + const { attributes, references } = await services.savedObjectsClient.get( + 'alert', + alertId + ); + + return { + state: map( + await promiseResult( + this.validateAndRunAlert(services, apiKey, attributes, references) + ), + (stateUpdates: State) => { + return { + ...stateUpdates, + previousStartedAt, + }; + }, + (err: Error) => { + this.logger.error(`Executing Alert "${alertId}" has resulted in Error: ${err.message}`); + return { + ...originalState, + previousStartedAt, + }; + } + ), + runAt: getNextRunAt( + new Date(this.taskInstance.startedAt!), + // we do not currently have a good way of returning the type + // from SavedObjectsClient, and as we currenrtly require a schedule + // and we only support `interval`, we can cast this safely + attributes.schedule as IntervalSchedule + ), + }; + } +} diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.test.ts b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.test.ts new file mode 100644 index 00000000000000..2ea1256352bec9 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.test.ts @@ -0,0 +1,87 @@ +/* + * 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 sinon from 'sinon'; +import { ConcreteTaskInstance, TaskStatus } from '../../../task_manager'; +import { TaskRunnerContext, TaskRunnerFactory } from './task_runner_factory'; +import { encryptedSavedObjectsMock } from '../../../../../plugins/encrypted_saved_objects/server/mocks'; +import { + savedObjectsClientMock, + loggingServiceMock, +} from '../../../../../../src/core/server/mocks'; + +const alertType = { + id: 'test', + name: 'My test alert', + actionGroups: ['default'], + executor: jest.fn(), +}; +let fakeTimer: sinon.SinonFakeTimers; + +describe('Task Runner Factory', () => { + let mockedTaskInstance: ConcreteTaskInstance; + + beforeAll(() => { + fakeTimer = sinon.useFakeTimers(); + mockedTaskInstance = { + id: '', + attempts: 0, + status: TaskStatus.Running, + version: '123', + runAt: new Date(), + scheduledAt: new Date(), + startedAt: new Date(), + retryAt: new Date(Date.now() + 5 * 60 * 1000), + state: { + startedAt: new Date(Date.now() - 5 * 60 * 1000), + }, + taskType: 'alerting:test', + params: { + alertId: '1', + }, + ownerId: null, + }; + }); + + afterAll(() => fakeTimer.restore()); + + const savedObjectsClient = savedObjectsClientMock.create(); + const encryptedSavedObjectsPlugin = encryptedSavedObjectsMock.createStart(); + const services = { + log: jest.fn(), + callCluster: jest.fn(), + savedObjectsClient, + }; + + const taskRunnerFactoryInitializerParams: jest.Mocked = { + getServices: jest.fn().mockReturnValue(services), + executeAction: jest.fn(), + encryptedSavedObjectsPlugin, + logger: loggingServiceMock.create().get(), + spaceIdToNamespace: jest.fn().mockReturnValue(undefined), + getBasePath: jest.fn().mockReturnValue(undefined), + }; + + beforeEach(() => { + jest.resetAllMocks(); + taskRunnerFactoryInitializerParams.getServices.mockReturnValue(services); + }); + + test(`throws an error if factory isn't initialized`, () => { + const factory = new TaskRunnerFactory(); + expect(() => + factory.create(alertType, { taskInstance: mockedTaskInstance }) + ).toThrowErrorMatchingInlineSnapshot(`"TaskRunnerFactory not initialized"`); + }); + + test(`throws an error if factory is already initialized`, () => { + const factory = new TaskRunnerFactory(); + factory.initialize(taskRunnerFactoryInitializerParams); + expect(() => + factory.initialize(taskRunnerFactoryInitializerParams) + ).toThrowErrorMatchingInlineSnapshot(`"TaskRunnerFactory already initialized"`); + }); +}); diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.ts b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.ts new file mode 100644 index 00000000000000..7186e1e729bda4 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.ts @@ -0,0 +1,46 @@ +/* + * 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 { Logger } from '../../../../../../src/core/server'; +import { RunContext } from '../../../task_manager'; +import { PluginStartContract as EncryptedSavedObjectsStartContract } from '../../../../../plugins/encrypted_saved_objects/server'; +import { PluginStartContract as ActionsPluginStartContract } from '../../../actions'; +import { + AlertType, + GetBasePathFunction, + GetServicesFunction, + SpaceIdToNamespaceFunction, +} from '../types'; +import { TaskRunner } from './task_runner'; + +export interface TaskRunnerContext { + logger: Logger; + getServices: GetServicesFunction; + executeAction: ActionsPluginStartContract['execute']; + encryptedSavedObjectsPlugin: EncryptedSavedObjectsStartContract; + spaceIdToNamespace: SpaceIdToNamespaceFunction; + getBasePath: GetBasePathFunction; +} + +export class TaskRunnerFactory { + private isInitialized = false; + private taskRunnerContext?: TaskRunnerContext; + + public initialize(taskRunnerContext: TaskRunnerContext) { + if (this.isInitialized) { + throw new Error('TaskRunnerFactory already initialized'); + } + this.isInitialized = true; + this.taskRunnerContext = taskRunnerContext; + } + + public create(alertType: AlertType, { taskInstance }: RunContext) { + if (!this.isInitialized) { + throw new Error('TaskRunnerFactory not initialized'); + } + + return new TaskRunner(alertType, taskInstance, this.taskRunnerContext!); + } +} diff --git a/x-pack/legacy/plugins/alerting/server/lib/transform_action_params.test.ts b/x-pack/legacy/plugins/alerting/server/task_runner/transform_action_params.test.ts similarity index 100% rename from x-pack/legacy/plugins/alerting/server/lib/transform_action_params.test.ts rename to x-pack/legacy/plugins/alerting/server/task_runner/transform_action_params.test.ts diff --git a/x-pack/legacy/plugins/alerting/server/lib/transform_action_params.ts b/x-pack/legacy/plugins/alerting/server/task_runner/transform_action_params.ts similarity index 100% rename from x-pack/legacy/plugins/alerting/server/lib/transform_action_params.ts rename to x-pack/legacy/plugins/alerting/server/task_runner/transform_action_params.ts diff --git a/x-pack/legacy/plugins/alerting/server/types.ts b/x-pack/legacy/plugins/alerting/server/types.ts index 62dcf07abb7bdd..9b03f9b02aa0af 100644 --- a/x-pack/legacy/plugins/alerting/server/types.ts +++ b/x-pack/legacy/plugins/alerting/server/types.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AlertInstance } from './lib'; +import { AlertInstance } from './alert_instance'; import { AlertTypeRegistry as OrigAlertTypeRegistry } from './alert_type_registry'; import { PluginSetupContract, PluginStartContract } from './plugin'; import { SavedObjectAttributes, SavedObjectsClientContract } from '../../../../../src/core/server'; diff --git a/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts b/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts index 487f396d7a3dca..c47649544f9a76 100644 --- a/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts +++ b/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts @@ -17,7 +17,7 @@ export interface AlertUtilsOpts { objectRemover?: ObjectRemover; } -export interface CreateAlwaysFiringActionOpts { +export interface CreateAlertWithActionOpts { indexRecordActionId?: string; objectRemover?: ObjectRemover; overwrites?: Record; @@ -159,7 +159,7 @@ export class AlertUtils { overwrites = {}, indexRecordActionId, reference, - }: CreateAlwaysFiringActionOpts) { + }: CreateAlertWithActionOpts) { const objRemover = objectRemover || this.objectRemover; const actionId = indexRecordActionId || this.indexRecordActionId; @@ -207,4 +207,47 @@ export class AlertUtils { } return response; } + + public async createAlwaysFailingAction({ + objectRemover, + overwrites = {}, + indexRecordActionId, + reference, + }: CreateAlertWithActionOpts) { + const objRemover = objectRemover || this.objectRemover; + const actionId = indexRecordActionId || this.indexRecordActionId; + + if (!objRemover) { + throw new Error('objectRemover is required'); + } + if (!actionId) { + throw new Error('indexRecordActionId is required '); + } + + let request = this.supertestWithoutAuth + .post(`${getUrlPrefix(this.space.id)}/api/alert`) + .set('kbn-xsrf', 'foo'); + if (this.user) { + request = request.auth(this.user.username, this.user.password); + } + const response = await request.send({ + enabled: true, + name: 'fail', + schedule: { interval: '30s' }, + throttle: '30s', + tags: [], + alertTypeId: 'test.failing', + consumer: 'bar', + params: { + index: ES_TEST_INDEX_NAME, + reference, + }, + actions: [], + ...overwrites, + }); + if (response.statusCode === 200) { + objRemover.add(this.space.id, response.body.id, 'alert'); + } + return response; + } } diff --git a/x-pack/test/alerting_api_integration/common/lib/index.ts b/x-pack/test/alerting_api_integration/common/lib/index.ts index a2f21264634f83..c1e59664f9ce2e 100644 --- a/x-pack/test/alerting_api_integration/common/lib/index.ts +++ b/x-pack/test/alerting_api_integration/common/lib/index.ts @@ -10,4 +10,5 @@ export { ES_TEST_INDEX_NAME, ESTestIndexTool } from './es_test_index_tool'; export { getTestAlertData } from './get_test_alert_data'; export { AlertUtils } from './alert_utils'; export { TaskManagerUtils } from './task_manager_utils'; +export * from './test_assertions'; export { checkAAD } from './check_aad'; diff --git a/x-pack/test/alerting_api_integration/common/lib/test_assertions.ts b/x-pack/test/alerting_api_integration/common/lib/test_assertions.ts new file mode 100644 index 00000000000000..9495dd4cfae82a --- /dev/null +++ b/x-pack/test/alerting_api_integration/common/lib/test_assertions.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 expect from '@kbn/expect'; + +export function ensureDatetimeIsWithinRange( + date: number, + expectedDiff: number, + buffer: number = 10000 +) { + const diff = date - Date.now(); + expect(diff).to.be.greaterThan(expectedDiff - buffer); + expect(diff).to.be.lessThan(expectedDiff + buffer); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts index e89b54b1caa554..2a7e0b22038248 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts @@ -7,7 +7,13 @@ import expect from '@kbn/expect'; import { Response as SupertestResponse } from 'supertest'; import { UserAtSpaceScenarios } from '../../scenarios'; -import { checkAAD, getUrlPrefix, getTestAlertData, ObjectRemover } from '../../../common/lib'; +import { + checkAAD, + getUrlPrefix, + getTestAlertData, + ObjectRemover, + ensureDatetimeIsWithinRange, +} from '../../../common/lib'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; // eslint-disable-next-line import/no-default-export @@ -406,10 +412,3 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { } }); } - -function ensureDatetimeIsWithinRange(scheduledRunTime: number, expectedDiff: number) { - const buffer = 10000; - const diff = scheduledRunTime - Date.now(); - expect(diff).to.be.greaterThan(expectedDiff - buffer); - expect(diff).to.be.lessThan(expectedDiff + buffer); -} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts.ts index 03e973194b4e2c..032fee15882cf8 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts.ts @@ -5,6 +5,7 @@ */ import expect from '@kbn/expect'; +import { Response as SupertestResponse } from 'supertest'; import { Spaces } from '../../scenarios'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; import { @@ -14,6 +15,7 @@ import { getTestAlertData, ObjectRemover, AlertUtils, + ensureDatetimeIsWithinRange, } from '../../../common/lib'; // eslint-disable-next-line import/no-default-export @@ -23,6 +25,13 @@ export default function alertTests({ getService }: FtrProviderContext) { const retry = getService('retry'); const esTestIndexTool = new ESTestIndexTool(es, retry); + function getAlertingTaskById(taskId: string) { + return supertestWithoutAuth + .get(`/api/alerting_tasks/${taskId}`) + .expect(200) + .then((response: SupertestResponse) => response.body); + } + describe('alerts', () => { let alertUtils: AlertUtils; let indexRecordActionId: string; @@ -100,6 +109,37 @@ export default function alertTests({ getService }: FtrProviderContext) { }); }); + it('should reschedule failing alerts using the alerting interval and not the Task Manager retry logic', async () => { + /* + Alerting does not use the Task Manager schedule and instead implements its own due to a current limitation + in TaskManager's ability to update an existing Task. + For this reason we need to handle the retry when Alert executors fail, as TaskManager doesn't understand that + alerting tasks are recurring tasks. + */ + const alertIntervalInSeconds = 30; + const reference = alertUtils.generateReference(); + const response = await alertUtils.createAlwaysFailingAction({ + reference, + overwrites: { schedule: { interval: `${alertIntervalInSeconds}s` } }, + }); + + expect(response.statusCode).to.eql(200); + + // wait for executor Alert Executor to be run, which means the underlying task is running + await esTestIndexTool.waitForDocs('alert:test.failing', reference); + + await retry.try(async () => { + const alertTask = (await getAlertingTaskById(response.body.scheduledTaskId)).docs[0]; + expect(alertTask.status).to.eql('idle'); + // ensure the alert is rescheduled to a minute from now + ensureDatetimeIsWithinRange( + Date.parse(alertTask.runAt), + alertIntervalInSeconds * 1000, + 5000 + ); + }); + }); + it('should handle custom retry logic', async () => { // We'll use this start time to query tasks created after this point const testStart = new Date();