diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4db1c2dd3b5eb..d21d6ad81a0c6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -255,6 +255,8 @@ # Security /src/core/server/csp/ @elastic/kibana-security @elastic/kibana-platform +/src/plugins/security_oss/ @elastic/kibana-security +/test/security_functional/ @elastic/kibana-security /x-pack/plugins/spaces/ @elastic/kibana-security /x-pack/plugins/encrypted_saved_objects/ @elastic/kibana-security /x-pack/plugins/security/ @elastic/kibana-security diff --git a/.i18nrc.json b/.i18nrc.json index 153a5a6cafece..7ecaa89f66604 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -37,6 +37,7 @@ "regionMap": "src/plugins/region_map", "savedObjects": "src/plugins/saved_objects", "savedObjectsManagement": "src/plugins/saved_objects_management", + "security": "src/plugins/security_oss", "server": "src/legacy/server", "statusPage": "src/legacy/core_plugins/status_page", "telemetry": [ diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 67b7aa8e6a011..052ab95e1a18b 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -155,6 +155,11 @@ It also provides a stateful version of it on the start contract. |WARNING: Missing README. +|{kib-repo}blob/{branch}/src/plugins/security_oss/README.md[securityOss] +|securityOss is responsible for educating users about Elastic's free security features, +so they can properly protect the data within their clusters. + + |{kib-repo}blob/{branch}/src/plugins/share/README.md[share] |Replaces the legacy ui/share module for registering share context menus. diff --git a/scripts/functional_tests.js b/scripts/functional_tests.js index 4facbe1ffbb07..2b338b1c054aa 100644 --- a/scripts/functional_tests.js +++ b/scripts/functional_tests.js @@ -23,6 +23,7 @@ const alwaysImportedTests = [ require.resolve('../test/plugin_functional/config.ts'), require.resolve('../test/ui_capabilities/newsfeed_err/config.ts'), require.resolve('../test/new_visualize_flow/config.js'), + require.resolve('../test/security_functional/config.ts'), ]; // eslint-disable-next-line no-restricted-syntax const onlyNotInCoverageTests = [ diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker index 959e1f8dc3e72..0039debe383bd 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker @@ -93,6 +93,7 @@ kibana_vars=( path.data pid.file regionmap + security.showInsecureClusterWarning server.basePath server.customResponseHeaders server.compression.enabled diff --git a/src/plugins/security_oss/README.md b/src/plugins/security_oss/README.md new file mode 100644 index 0000000000000..6143149fec384 --- /dev/null +++ b/src/plugins/security_oss/README.md @@ -0,0 +1,4 @@ +# `securityOss` plugin + +`securityOss` is responsible for educating users about Elastic's free security features, +so they can properly protect the data within their clusters. diff --git a/src/plugins/security_oss/kibana.json b/src/plugins/security_oss/kibana.json new file mode 100644 index 0000000000000..70e37d586f1db --- /dev/null +++ b/src/plugins/security_oss/kibana.json @@ -0,0 +1,10 @@ +{ + "id": "securityOss", + "version": "8.0.0", + "kibanaVersion": "kibana", + "configPath": ["security"], + "ui": true, + "server": true, + "requiredPlugins": [], + "requiredBundles": [] +} diff --git a/src/plugins/security_oss/public/config.ts b/src/plugins/security_oss/public/config.ts new file mode 100644 index 0000000000000..17f6b5a53eb6c --- /dev/null +++ b/src/plugins/security_oss/public/config.ts @@ -0,0 +1,22 @@ +/* + * 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. + */ + +export interface ConfigType { + showInsecureClusterWarning: boolean; +} diff --git a/src/plugins/security_oss/public/index.ts b/src/plugins/security_oss/public/index.ts new file mode 100644 index 0000000000000..2e63a9316c99b --- /dev/null +++ b/src/plugins/security_oss/public/index.ts @@ -0,0 +1,26 @@ +/* + * 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 { PluginInitializerContext } from 'kibana/public'; + +import { SecurityOssPlugin } from './plugin'; + +export { SecurityOssPluginSetup, SecurityOssPluginStart } from './plugin'; +export const plugin = (initializerContext: PluginInitializerContext) => + new SecurityOssPlugin(initializerContext); diff --git a/src/plugins/security_oss/public/insecure_cluster_service/components/default_alert.test.tsx b/src/plugins/security_oss/public/insecure_cluster_service/components/default_alert.test.tsx new file mode 100644 index 0000000000000..b414ab78cdfdb --- /dev/null +++ b/src/plugins/security_oss/public/insecure_cluster_service/components/default_alert.test.tsx @@ -0,0 +1,37 @@ +/* + * 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 { defaultAlertText } from './default_alert'; + +describe('defaultAlertText', () => { + it('creates a valid MountPoint that can cleanup correctly', () => { + const mountPoint = defaultAlertText(jest.fn()); + + const el = document.createElement('div'); + const unmount = mountPoint(el); + + expect(el.querySelectorAll('[data-test-subj="insecureClusterDefaultAlertText"]')).toHaveLength( + 1 + ); + + unmount(); + + expect(el).toMatchInlineSnapshot(`
`); + }); +}); diff --git a/src/plugins/security_oss/public/insecure_cluster_service/components/default_alert.tsx b/src/plugins/security_oss/public/insecure_cluster_service/components/default_alert.tsx new file mode 100644 index 0000000000000..f2eeedb5b7372 --- /dev/null +++ b/src/plugins/security_oss/public/insecure_cluster_service/components/default_alert.tsx @@ -0,0 +1,97 @@ +/* + * 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 { + EuiButton, + EuiCheckbox, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { I18nProvider, FormattedMessage } from '@kbn/i18n/react'; +import { MountPoint } from 'kibana/public'; +import React, { useState } from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; + +export const defaultAlertTitle = i18n.translate('security.checkup.insecureClusterTitle', { + defaultMessage: 'Please secure your installation', +}); + +export const defaultAlertText: (onDismiss: (persist: boolean) => void) => MountPoint = ( + onDismiss +) => (e) => { + const AlertText = () => { + const [persist, setPersist] = useState(false); + + return ( + +
+ + + + + setPersist(changeEvent.target.checked)} + label={i18n.translate('security.checkup.dontShowAgain', { + defaultMessage: `Don't show again`, + })} + /> + + + + + {i18n.translate('security.checkup.learnMoreButtonText', { + defaultMessage: `Learn more`, + })} + + + + onDismiss(persist)} + data-test-subj="defaultDismissAlertButton" + > + {i18n.translate('security.checkup.dismissButtonText', { + defaultMessage: `Dismiss`, + })} + + + +
+
+ ); + }; + + render(, e); + + return () => unmountComponentAtNode(e); +}; diff --git a/src/plugins/security_oss/public/insecure_cluster_service/components/index.ts b/src/plugins/security_oss/public/insecure_cluster_service/components/index.ts new file mode 100644 index 0000000000000..9334dad2b8193 --- /dev/null +++ b/src/plugins/security_oss/public/insecure_cluster_service/components/index.ts @@ -0,0 +1,20 @@ +/* + * 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. + */ + +export { defaultAlertTitle, defaultAlertText } from './default_alert'; diff --git a/src/plugins/security_oss/public/insecure_cluster_service/index.ts b/src/plugins/security_oss/public/insecure_cluster_service/index.ts new file mode 100644 index 0000000000000..7817dc383c168 --- /dev/null +++ b/src/plugins/security_oss/public/insecure_cluster_service/index.ts @@ -0,0 +1,24 @@ +/* + * 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. + */ + +export { + InsecureClusterService, + InsecureClusterServiceSetup, + InsecureClusterServiceStart, +} from './insecure_cluster_service'; diff --git a/src/plugins/security_oss/public/insecure_cluster_service/insecure_cluster_service.mock.tsx b/src/plugins/security_oss/public/insecure_cluster_service/insecure_cluster_service.mock.tsx new file mode 100644 index 0000000000000..630becb49dd4c --- /dev/null +++ b/src/plugins/security_oss/public/insecure_cluster_service/insecure_cluster_service.mock.tsx @@ -0,0 +1,37 @@ +/* + * 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 { + InsecureClusterServiceSetup, + InsecureClusterServiceStart, +} from './insecure_cluster_service'; + +export const mockInsecureClusterService = { + createSetup: () => { + return { + setAlertTitle: jest.fn(), + setAlertText: jest.fn(), + } as InsecureClusterServiceSetup; + }, + createStart: () => { + return { + hideAlert: jest.fn(), + } as InsecureClusterServiceStart; + }, +}; diff --git a/src/plugins/security_oss/public/insecure_cluster_service/insecure_cluster_service.test.tsx b/src/plugins/security_oss/public/insecure_cluster_service/insecure_cluster_service.test.tsx new file mode 100644 index 0000000000000..a81f361689743 --- /dev/null +++ b/src/plugins/security_oss/public/insecure_cluster_service/insecure_cluster_service.test.tsx @@ -0,0 +1,336 @@ +/* + * 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 { InsecureClusterService } from './insecure_cluster_service'; +import { ConfigType } from '../config'; +import { coreMock } from '../../../../core/public/mocks'; +import { nextTick } from 'test_utils/enzyme_helpers'; + +let mockOnDismissCallback: (persist: boolean) => void = jest.fn().mockImplementation(() => { + throw new Error('expected callback to be replaced!'); +}); + +jest.mock('./components', () => { + return { + defaultAlertTitle: 'mocked default alert title', + defaultAlertText: (onDismiss: any) => { + mockOnDismissCallback = onDismiss; + return 'mocked default alert text'; + }, + }; +}); + +interface InitOpts { + displayAlert?: boolean; + isAnonymousPath?: boolean; + tenant?: string; +} + +function initCore({ + displayAlert = true, + isAnonymousPath = false, + tenant = '/server-base-path', +}: InitOpts = {}) { + const coreSetup = coreMock.createSetup(); + (coreSetup.http.basePath.serverBasePath as string) = tenant; + + const coreStart = coreMock.createStart(); + coreStart.http.get.mockImplementation(async (url: unknown) => { + if (url === '/internal/security_oss/display_insecure_cluster_alert') { + return { displayAlert }; + } + throw new Error(`unexpected call to http.get: ${url}`); + }); + coreStart.http.anonymousPaths.isAnonymous.mockReturnValue(isAnonymousPath); + + coreStart.notifications.toasts.addWarning.mockReturnValue({ id: 'mock_alert_id' }); + return { coreSetup, coreStart }; +} + +describe('InsecureClusterService', () => { + describe('display scenarios', () => { + it('does not display an alert when the warning is explicitly disabled via config', async () => { + const config: ConfigType = { showInsecureClusterWarning: false }; + const { coreSetup, coreStart } = initCore({ displayAlert: true }); + const storage = coreMock.createStorage(); + + const service = new InsecureClusterService(config, storage); + service.setup({ core: coreSetup }); + service.start({ core: coreStart }); + + await nextTick(); + + expect(coreStart.http.get).not.toHaveBeenCalled(); + expect(coreStart.notifications.toasts.addWarning).not.toHaveBeenCalled(); + + expect(coreStart.notifications.toasts.remove).not.toHaveBeenCalled(); + expect(storage.setItem).not.toHaveBeenCalled(); + }); + + it('does not display an alert when the endpoint check returns false', async () => { + const config: ConfigType = { showInsecureClusterWarning: true }; + const { coreSetup, coreStart } = initCore({ displayAlert: false }); + const storage = coreMock.createStorage(); + + const service = new InsecureClusterService(config, storage); + service.setup({ core: coreSetup }); + service.start({ core: coreStart }); + + await nextTick(); + + expect(coreStart.http.get).toHaveBeenCalledTimes(1); + expect(coreStart.notifications.toasts.addWarning).not.toHaveBeenCalled(); + + expect(coreStart.notifications.toasts.remove).not.toHaveBeenCalled(); + expect(storage.setItem).not.toHaveBeenCalled(); + }); + + it('does not display an alert when on an anonymous path', async () => { + const config: ConfigType = { showInsecureClusterWarning: true }; + const { coreSetup, coreStart } = initCore({ displayAlert: true, isAnonymousPath: true }); + const storage = coreMock.createStorage(); + + const service = new InsecureClusterService(config, storage); + service.setup({ core: coreSetup }); + service.start({ core: coreStart }); + + await nextTick(); + + expect(coreStart.http.get).not.toHaveBeenCalled(); + expect(coreStart.notifications.toasts.addWarning).not.toHaveBeenCalled(); + + expect(coreStart.notifications.toasts.remove).not.toHaveBeenCalled(); + expect(storage.setItem).not.toHaveBeenCalled(); + }); + + it('only reads storage information from the current tenant', async () => { + const config: ConfigType = { showInsecureClusterWarning: true }; + const { coreSetup, coreStart } = initCore({ + displayAlert: true, + tenant: '/my-specific-tenant', + }); + + const storage = coreMock.createStorage(); + storage.getItem.mockReturnValue(JSON.stringify({ show: false })); + + const service = new InsecureClusterService(config, storage); + service.setup({ core: coreSetup }); + service.start({ core: coreStart }); + + await nextTick(); + + expect(storage.getItem).toHaveBeenCalledTimes(1); + expect(storage.getItem).toHaveBeenCalledWith( + 'insecureClusterWarningVisibility/my-specific-tenant' + ); + }); + + it('does not display an alert when hidden via storage', async () => { + const config: ConfigType = { showInsecureClusterWarning: true }; + const { coreSetup, coreStart } = initCore({ displayAlert: true }); + + const storage = coreMock.createStorage(); + storage.getItem.mockReturnValue(JSON.stringify({ show: false })); + + const service = new InsecureClusterService(config, storage); + service.setup({ core: coreSetup }); + service.start({ core: coreStart }); + + await nextTick(); + + expect(coreStart.http.get).not.toHaveBeenCalled(); + expect(coreStart.notifications.toasts.addWarning).not.toHaveBeenCalled(); + + expect(coreStart.notifications.toasts.remove).not.toHaveBeenCalled(); + expect(storage.setItem).not.toHaveBeenCalled(); + }); + + it('displays an alert when persisted preference is corrupted', async () => { + const config: ConfigType = { showInsecureClusterWarning: true }; + const { coreSetup, coreStart } = initCore({ displayAlert: true }); + + const storage = coreMock.createStorage(); + storage.getItem.mockReturnValue('{ this is a string of invalid JSON'); + + const service = new InsecureClusterService(config, storage); + service.setup({ core: coreSetup }); + service.start({ core: coreStart }); + + await nextTick(); + + expect(coreStart.http.get).toHaveBeenCalledTimes(1); + expect(coreStart.notifications.toasts.addWarning).toHaveBeenCalledTimes(1); + + expect(coreStart.notifications.toasts.remove).not.toHaveBeenCalled(); + expect(storage.setItem).not.toHaveBeenCalled(); + }); + + it('displays an alert when enabled via config and endpoint checks', async () => { + const config: ConfigType = { showInsecureClusterWarning: true }; + const { coreSetup, coreStart } = initCore({ displayAlert: true }); + const storage = coreMock.createStorage(); + + const service = new InsecureClusterService(config, storage); + service.setup({ core: coreSetup }); + service.start({ core: coreStart }); + + await nextTick(); + + expect(coreStart.http.get).toHaveBeenCalledTimes(1); + expect(coreStart.notifications.toasts.addWarning).toHaveBeenCalledTimes(1); + expect(coreStart.notifications.toasts.addWarning.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "iconType": "alert", + "text": "mocked default alert text", + "title": "mocked default alert title", + }, + Object { + "toastLifeTimeMs": 864000000, + }, + ] + `); + + expect(coreStart.notifications.toasts.remove).not.toHaveBeenCalled(); + expect(storage.setItem).not.toHaveBeenCalled(); + }); + + it('dismisses the alert when requested, and remembers this preference', async () => { + const config: ConfigType = { showInsecureClusterWarning: true }; + const { coreSetup, coreStart } = initCore({ displayAlert: true }); + const storage = coreMock.createStorage(); + + const service = new InsecureClusterService(config, storage); + service.setup({ core: coreSetup }); + service.start({ core: coreStart }); + + await nextTick(); + + expect(coreStart.http.get).toHaveBeenCalledTimes(1); + expect(coreStart.notifications.toasts.addWarning).toHaveBeenCalledTimes(1); + + mockOnDismissCallback(true); + + expect(coreStart.notifications.toasts.remove).toHaveBeenCalledTimes(1); + expect(storage.setItem).toHaveBeenCalledWith( + 'insecureClusterWarningVisibility/server-base-path', + JSON.stringify({ show: false }) + ); + }); + }); + + describe('#setup', () => { + it('allows the alert title and text to be replaced exactly once', async () => { + const config: ConfigType = { showInsecureClusterWarning: true }; + const storage = coreMock.createStorage(); + + const { coreSetup } = initCore(); + + const service = new InsecureClusterService(config, storage); + const { setAlertTitle, setAlertText } = service.setup({ core: coreSetup }); + setAlertTitle('some new title'); + setAlertText('some new alert text'); + + expect(() => setAlertTitle('')).toThrowErrorMatchingInlineSnapshot( + `"alert title has already been set"` + ); + expect(() => setAlertText('')).toThrowErrorMatchingInlineSnapshot( + `"alert text has already been set"` + ); + }); + + it('allows the alert title and text to be replaced', async () => { + const config: ConfigType = { showInsecureClusterWarning: true }; + const { coreSetup, coreStart } = initCore({ displayAlert: true }); + const storage = coreMock.createStorage(); + + const service = new InsecureClusterService(config, storage); + const { setAlertTitle, setAlertText } = service.setup({ core: coreSetup }); + setAlertTitle('some new title'); + setAlertText('some new alert text'); + + service.start({ core: coreStart }); + + await nextTick(); + + expect(coreStart.http.get).toHaveBeenCalledTimes(1); + expect(coreStart.notifications.toasts.addWarning).toHaveBeenCalledTimes(1); + expect(coreStart.notifications.toasts.addWarning.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "iconType": "alert", + "text": "some new alert text", + "title": "some new title", + }, + Object { + "toastLifeTimeMs": 864000000, + }, + ] + `); + + expect(coreStart.notifications.toasts.remove).not.toHaveBeenCalled(); + expect(storage.setItem).not.toHaveBeenCalled(); + }); + }); + + describe('#start', () => { + it('allows the alert to be hidden via start contract, and remembers this preference', async () => { + const config: ConfigType = { showInsecureClusterWarning: true }; + const { coreSetup, coreStart } = initCore({ displayAlert: true }); + const storage = coreMock.createStorage(); + + const service = new InsecureClusterService(config, storage); + service.setup({ core: coreSetup }); + const { hideAlert } = service.start({ core: coreStart }); + + await nextTick(); + + expect(coreStart.http.get).toHaveBeenCalledTimes(1); + expect(coreStart.notifications.toasts.addWarning).toHaveBeenCalledTimes(1); + + hideAlert(true); + + expect(coreStart.notifications.toasts.remove).toHaveBeenCalledTimes(1); + expect(storage.setItem).toHaveBeenCalledWith( + 'insecureClusterWarningVisibility/server-base-path', + JSON.stringify({ show: false }) + ); + }); + + it('allows the alert to be hidden via start contract, and does not remember the preference', async () => { + const config: ConfigType = { showInsecureClusterWarning: true }; + const { coreSetup, coreStart } = initCore({ displayAlert: true }); + const storage = coreMock.createStorage(); + + const service = new InsecureClusterService(config, storage); + service.setup({ core: coreSetup }); + const { hideAlert } = service.start({ core: coreStart }); + + await nextTick(); + + expect(coreStart.http.get).toHaveBeenCalledTimes(1); + expect(coreStart.notifications.toasts.addWarning).toHaveBeenCalledTimes(1); + + hideAlert(false); + + expect(coreStart.notifications.toasts.remove).toHaveBeenCalledTimes(1); + expect(storage.setItem).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/plugins/security_oss/public/insecure_cluster_service/insecure_cluster_service.tsx b/src/plugins/security_oss/public/insecure_cluster_service/insecure_cluster_service.tsx new file mode 100644 index 0000000000000..e6255233354b7 --- /dev/null +++ b/src/plugins/security_oss/public/insecure_cluster_service/insecure_cluster_service.tsx @@ -0,0 +1,164 @@ +/* + * 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 { CoreSetup, CoreStart, MountPoint, Toast } from 'kibana/public'; + +import { BehaviorSubject, combineLatest, from } from 'rxjs'; +import { distinctUntilChanged, map } from 'rxjs/operators'; +import { ConfigType } from '../config'; +import { defaultAlertText, defaultAlertTitle } from './components'; + +interface SetupDeps { + core: Pick; +} + +interface StartDeps { + core: Pick; +} + +export interface InsecureClusterServiceSetup { + setAlertTitle: (alertTitle: string | MountPoint) => void; + setAlertText: (alertText: string | MountPoint) => void; +} + +export interface InsecureClusterServiceStart { + hideAlert: (persist: boolean) => void; +} + +export class InsecureClusterService { + private enabled: boolean; + + private alertVisibility$: BehaviorSubject; + + private storage: Storage; + + private alertToast?: Toast; + + private alertTitle?: string | MountPoint; + + private alertText?: string | MountPoint; + + private storageKey?: string; + + constructor(config: Pick, storage: Storage) { + this.storage = storage; + this.enabled = config.showInsecureClusterWarning; + this.alertVisibility$ = new BehaviorSubject(this.enabled); + } + + public setup({ core }: SetupDeps): InsecureClusterServiceSetup { + const tenant = core.http.basePath.serverBasePath; + this.storageKey = `insecureClusterWarningVisibility${tenant}`; + this.enabled = this.enabled && this.getPersistedVisibilityPreference(); + this.alertVisibility$.next(this.enabled); + + return { + setAlertTitle: (alertTitle: string | MountPoint) => { + if (this.alertTitle) { + throw new Error('alert title has already been set'); + } + this.alertTitle = alertTitle; + }, + setAlertText: (alertText: string | MountPoint) => { + if (this.alertText) { + throw new Error('alert text has already been set'); + } + this.alertText = alertText; + }, + }; + } + + public start({ core }: StartDeps): InsecureClusterServiceStart { + const shouldInitialize = + this.enabled && !core.http.anonymousPaths.isAnonymous(window.location.pathname); + + if (shouldInitialize) { + this.initializeAlert(core); + } + + return { + hideAlert: (persist: boolean) => this.setAlertVisibility(false, persist), + }; + } + + private initializeAlert(core: StartDeps['core']) { + const displayAlert$ = from( + core.http + .get<{ displayAlert: boolean }>('/internal/security_oss/display_insecure_cluster_alert') + .catch((e) => { + // in the event we can't make this call, assume we shouldn't display this alert. + return { displayAlert: false }; + }) + ); + + // 10 days is reasonably long enough to call "forever" for a page load. + // Can't go too much longer than this. See https://github.com/elastic/kibana/issues/64264#issuecomment-618400354 + const oneMinute = 60000; + const tenDays = oneMinute * 60 * 24 * 10; + + combineLatest([displayAlert$, this.alertVisibility$]) + .pipe( + map(([{ displayAlert }, isAlertVisible]) => displayAlert && isAlertVisible), + distinctUntilChanged() + ) + .subscribe((showAlert) => { + if (showAlert && !this.alertToast) { + this.alertToast = core.notifications.toasts.addWarning( + { + title: this.alertTitle ?? defaultAlertTitle, + text: + this.alertText ?? + defaultAlertText((persist: boolean) => this.setAlertVisibility(false, persist)), + iconType: 'alert', + }, + { + toastLifeTimeMs: tenDays, + } + ); + } else if (!showAlert && this.alertToast) { + core.notifications.toasts.remove(this.alertToast); + this.alertToast = undefined; + } + }); + } + + private setAlertVisibility(show: boolean, persist: boolean) { + if (!this.enabled) { + return; + } + this.alertVisibility$.next(show); + if (persist) { + this.setPersistedVisibilityPreference(show); + } + } + + private getPersistedVisibilityPreference() { + const entry = this.storage.getItem(this.storageKey!) ?? '{}'; + try { + const { show = true } = JSON.parse(entry); + return show; + } catch (e) { + return true; + } + } + + private setPersistedVisibilityPreference(show: boolean) { + this.storage.setItem(this.storageKey!, JSON.stringify({ show })); + } +} diff --git a/src/plugins/security_oss/public/mocks.ts b/src/plugins/security_oss/public/mocks.ts new file mode 100644 index 0000000000000..f4913d2de671b --- /dev/null +++ b/src/plugins/security_oss/public/mocks.ts @@ -0,0 +1,20 @@ +/* + * 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. + */ + +export { mockSecurityOssPlugin } from './plugin.mock'; diff --git a/src/plugins/security_oss/public/plugin.mock.ts b/src/plugins/security_oss/public/plugin.mock.ts new file mode 100644 index 0000000000000..c513d241dccbb --- /dev/null +++ b/src/plugins/security_oss/public/plugin.mock.ts @@ -0,0 +1,34 @@ +/* + * 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 { mockInsecureClusterService } from './insecure_cluster_service/insecure_cluster_service.mock'; +import { SecurityOssPluginSetup, SecurityOssPluginStart } from './plugin'; + +export const mockSecurityOssPlugin = { + createSetup: () => { + return { + insecureCluster: mockInsecureClusterService.createSetup(), + } as DeeplyMockedKeys; + }, + createStart: () => { + return { + insecureCluster: mockInsecureClusterService.createStart(), + } as DeeplyMockedKeys; + }, +}; diff --git a/src/plugins/security_oss/public/plugin.ts b/src/plugins/security_oss/public/plugin.ts new file mode 100644 index 0000000000000..2f3eed0bde5eb --- /dev/null +++ b/src/plugins/security_oss/public/plugin.ts @@ -0,0 +1,58 @@ +/* + * 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 { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public'; +import { ConfigType } from './config'; +import { + InsecureClusterService, + InsecureClusterServiceSetup, + InsecureClusterServiceStart, +} from './insecure_cluster_service'; + +export interface SecurityOssPluginSetup { + insecureCluster: InsecureClusterServiceSetup; +} + +export interface SecurityOssPluginStart { + insecureCluster: InsecureClusterServiceStart; +} + +export class SecurityOssPlugin + implements Plugin { + private readonly config: ConfigType; + + private insecureClusterService: InsecureClusterService; + + constructor(private readonly initializerContext: PluginInitializerContext) { + this.config = this.initializerContext.config.get(); + this.insecureClusterService = new InsecureClusterService(this.config, localStorage); + } + + public setup(core: CoreSetup) { + return { + insecureCluster: this.insecureClusterService.setup({ core }), + }; + } + + public start(core: CoreStart) { + return { + insecureCluster: this.insecureClusterService.start({ core }), + }; + } +} diff --git a/src/plugins/security_oss/server/check_cluster_data.test.ts b/src/plugins/security_oss/server/check_cluster_data.test.ts new file mode 100644 index 0000000000000..a8245931daf04 --- /dev/null +++ b/src/plugins/security_oss/server/check_cluster_data.test.ts @@ -0,0 +1,148 @@ +/* + * 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 { elasticsearchServiceMock, loggingSystemMock } from '../../../core/server/mocks'; +import { createClusterDataCheck } from './check_cluster_data'; + +describe('checkClusterForUserData', () => { + it('returns false if no data is found', async () => { + const esClient = elasticsearchServiceMock.createElasticsearchClient(); + esClient.cat.indices.mockResolvedValue( + elasticsearchServiceMock.createApiResponse({ body: [] }) + ); + + const log = loggingSystemMock.createLogger(); + + const response = await createClusterDataCheck()(esClient, log); + expect(response).toEqual(false); + expect(esClient.cat.indices).toHaveBeenCalledTimes(1); + }); + + it('returns false if data only exists in system indices', async () => { + const esClient = elasticsearchServiceMock.createElasticsearchClient(); + esClient.cat.indices.mockResolvedValue( + elasticsearchServiceMock.createApiResponse({ + body: [ + { + index: '.kibana', + 'docs.count': 500, + }, + { + index: 'kibana_sample_ecommerce_data', + 'docs.count': 20, + }, + { + index: '.somethingElse', + 'docs.count': 20, + }, + ], + }) + ); + + const log = loggingSystemMock.createLogger(); + + const response = await createClusterDataCheck()(esClient, log); + expect(response).toEqual(false); + expect(esClient.cat.indices).toHaveBeenCalledTimes(1); + }); + + it('returns true if data exists in non-system indices', async () => { + const esClient = elasticsearchServiceMock.createElasticsearchClient(); + esClient.cat.indices.mockResolvedValue( + elasticsearchServiceMock.createApiResponse({ + body: [ + { + index: '.kibana', + 'docs.count': 500, + }, + { + index: 'some_real_index', + 'docs.count': 20, + }, + ], + }) + ); + + const log = loggingSystemMock.createLogger(); + + const response = await createClusterDataCheck()(esClient, log); + expect(response).toEqual(true); + }); + + it('checks each time until the first true response is returned, then stops checking', async () => { + const esClient = elasticsearchServiceMock.createElasticsearchClient(); + esClient.cat.indices + .mockResolvedValueOnce( + elasticsearchServiceMock.createApiResponse({ + body: [], + }) + ) + .mockRejectedValueOnce(new Error('something terrible happened')) + .mockResolvedValueOnce( + elasticsearchServiceMock.createApiResponse({ + body: [ + { + index: '.kibana', + 'docs.count': 500, + }, + ], + }) + ) + .mockResolvedValueOnce( + elasticsearchServiceMock.createApiResponse({ + body: [ + { + index: 'some_real_index', + 'docs.count': 20, + }, + ], + }) + ); + + const log = loggingSystemMock.createLogger(); + + const doesClusterHaveUserData = createClusterDataCheck(); + + let response = await doesClusterHaveUserData(esClient, log); + expect(response).toEqual(false); + + response = await doesClusterHaveUserData(esClient, log); + expect(response).toEqual(false); + + response = await doesClusterHaveUserData(esClient, log); + expect(response).toEqual(false); + + response = await doesClusterHaveUserData(esClient, log); + expect(response).toEqual(true); + + expect(esClient.cat.indices).toHaveBeenCalledTimes(4); + expect(log.warn.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "Error encountered while checking cluster for user data: Error: something terrible happened", + ], + ] + `); + + response = await doesClusterHaveUserData(esClient, log); + expect(response).toEqual(true); + // Same number of calls as above. We should not have to interrogate again. + expect(esClient.cat.indices).toHaveBeenCalledTimes(4); + }); +}); diff --git a/src/plugins/security_oss/server/check_cluster_data.ts b/src/plugins/security_oss/server/check_cluster_data.ts new file mode 100644 index 0000000000000..a3aeb50ae280a --- /dev/null +++ b/src/plugins/security_oss/server/check_cluster_data.ts @@ -0,0 +1,47 @@ +/* + * 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 { ElasticsearchClient, Logger } from 'kibana/server'; + +export const createClusterDataCheck = () => { + let clusterHasUserData = false; + + return async function doesClusterHaveUserData(esClient: ElasticsearchClient, log: Logger) { + if (!clusterHasUserData) { + try { + const indices = await esClient.cat.indices< + Array<{ index: string; ['docs.count']: string }> + >({ + format: 'json', + h: ['index', 'docs.count'], + }); + clusterHasUserData = indices.body.some((indexCount) => { + const isInternalIndex = + indexCount.index.startsWith('.') || indexCount.index.startsWith('kibana_sample_'); + + return !isInternalIndex && parseInt(indexCount['docs.count'], 10) > 0; + }); + } catch (e) { + log.warn(`Error encountered while checking cluster for user data: ${e}`); + clusterHasUserData = false; + } + } + return clusterHasUserData; + }; +}; diff --git a/src/plugins/security_oss/server/config.ts b/src/plugins/security_oss/server/config.ts new file mode 100644 index 0000000000000..17fb46065aee5 --- /dev/null +++ b/src/plugins/security_oss/server/config.ts @@ -0,0 +1,26 @@ +/* + * 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 { schema, TypeOf } from '@kbn/config-schema'; + +export type ConfigType = TypeOf; + +export const ConfigSchema = schema.object({ + showInsecureClusterWarning: schema.boolean({ defaultValue: true }), +}); diff --git a/src/plugins/security_oss/server/index.ts b/src/plugins/security_oss/server/index.ts new file mode 100644 index 0000000000000..f35ae39daaff3 --- /dev/null +++ b/src/plugins/security_oss/server/index.ts @@ -0,0 +1,35 @@ +/* + * 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 { TypeOf } from '@kbn/config-schema'; + +import { PluginConfigDescriptor, PluginInitializerContext } from 'kibana/server'; +import { ConfigSchema } from './config'; +import { SecurityOssPlugin } from './plugin'; + +export { SecurityOssPluginSetup } from './plugin'; + +export const config: PluginConfigDescriptor> = { + schema: ConfigSchema, + exposeToBrowser: { + showInsecureClusterWarning: true, + }, +}; + +export const plugin = (context: PluginInitializerContext) => new SecurityOssPlugin(context); diff --git a/src/plugins/security_oss/server/plugin.test.ts b/src/plugins/security_oss/server/plugin.test.ts new file mode 100644 index 0000000000000..417da0c7e73bb --- /dev/null +++ b/src/plugins/security_oss/server/plugin.test.ts @@ -0,0 +1,37 @@ +/* + * 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 { coreMock } from '../../../core/server/mocks'; +import { SecurityOssPlugin } from './plugin'; + +describe('SecurityOss Plugin', () => { + describe('#setup', () => { + it('exposes the proper contract', async () => { + const context = coreMock.createPluginInitializerContext(); + const plugin = new SecurityOssPlugin(context); + const core = coreMock.createSetup(); + const contract = plugin.setup(core); + expect(Object.keys(contract)).toMatchInlineSnapshot(` + Array [ + "showInsecureClusterWarning$", + ] + `); + }); + }); +}); diff --git a/src/plugins/security_oss/server/plugin.ts b/src/plugins/security_oss/server/plugin.ts new file mode 100644 index 0000000000000..e48827f21a13a --- /dev/null +++ b/src/plugins/security_oss/server/plugin.ts @@ -0,0 +1,62 @@ +/* + * 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 { CoreSetup, Logger, Plugin, PluginInitializerContext } from 'kibana/server'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { createClusterDataCheck } from './check_cluster_data'; +import { ConfigType } from './config'; +import { setupDisplayInsecureClusterAlertRoute } from './routes'; + +export interface SecurityOssPluginSetup { + /** + * Allows consumers to show/hide the insecure cluster warning. + */ + showInsecureClusterWarning$: BehaviorSubject; +} + +export class SecurityOssPlugin implements Plugin { + private readonly config$: Observable; + private readonly logger: Logger; + + constructor(initializerContext: PluginInitializerContext) { + this.config$ = initializerContext.config.create(); + this.logger = initializerContext.logger.get(); + } + + public setup(core: CoreSetup) { + const router = core.http.createRouter(); + const showInsecureClusterWarning$ = new BehaviorSubject(true); + + setupDisplayInsecureClusterAlertRoute({ + router, + log: this.logger, + config$: this.config$, + displayModifier$: showInsecureClusterWarning$, + doesClusterHaveUserData: createClusterDataCheck(), + }); + + return { + showInsecureClusterWarning$, + }; + } + + public start() {} + + public stop() {} +} diff --git a/src/plugins/security_oss/server/routes/display_insecure_cluster_alert.ts b/src/plugins/security_oss/server/routes/display_insecure_cluster_alert.ts new file mode 100644 index 0000000000000..0f0f72f054b4c --- /dev/null +++ b/src/plugins/security_oss/server/routes/display_insecure_cluster_alert.ts @@ -0,0 +1,63 @@ +/* + * 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 { IRouter, Logger } from 'kibana/server'; +import { combineLatest, Observable } from 'rxjs'; +import { createClusterDataCheck } from '../check_cluster_data'; +import { ConfigType } from '../config'; + +interface Deps { + router: IRouter; + log: Logger; + config$: Observable; + displayModifier$: Observable; + doesClusterHaveUserData: ReturnType; +} + +export const setupDisplayInsecureClusterAlertRoute = ({ + router, + log, + config$, + displayModifier$, + doesClusterHaveUserData, +}: Deps) => { + let showInsecureClusterWarning = false; + + combineLatest([config$, displayModifier$]).subscribe(([config, displayModifier]) => { + showInsecureClusterWarning = config.showInsecureClusterWarning && displayModifier; + }); + + router.get( + { + path: '/internal/security_oss/display_insecure_cluster_alert', + validate: false, + }, + async (context, request, response) => { + if (!showInsecureClusterWarning) { + return response.ok({ body: { displayAlert: false } }); + } + + const hasData = await doesClusterHaveUserData( + context.core.elasticsearch.client.asInternalUser, + log + ); + return response.ok({ body: { displayAlert: hasData } }); + } + ); +}; diff --git a/src/plugins/security_oss/server/routes/index.ts b/src/plugins/security_oss/server/routes/index.ts new file mode 100644 index 0000000000000..ceff0b12c9cb1 --- /dev/null +++ b/src/plugins/security_oss/server/routes/index.ts @@ -0,0 +1,20 @@ +/* + * 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. + */ + +export { setupDisplayInsecureClusterAlertRoute } from './display_insecure_cluster_alert'; diff --git a/src/plugins/security_oss/server/routes/integration_tests/display_insecure_cluster_alert.test.ts b/src/plugins/security_oss/server/routes/integration_tests/display_insecure_cluster_alert.test.ts new file mode 100644 index 0000000000000..d62a5040be6b3 --- /dev/null +++ b/src/plugins/security_oss/server/routes/integration_tests/display_insecure_cluster_alert.test.ts @@ -0,0 +1,134 @@ +/* + * 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 { loggingSystemMock } from '../../../../../core/server/mocks'; +import { setupServer } from '../../../../../core/server/test_utils'; +import { setupDisplayInsecureClusterAlertRoute } from '../display_insecure_cluster_alert'; +import { ConfigType } from '../../config'; +import { BehaviorSubject, of } from 'rxjs'; +import { UnwrapPromise } from '@kbn/utility-types'; +import { createClusterDataCheck } from '../../check_cluster_data'; +import supertest from 'supertest'; + +type SetupServerReturn = UnwrapPromise>; +const pluginId = Symbol('securityOss'); + +interface SetupOpts { + config?: ConfigType; + displayModifier$?: BehaviorSubject; + doesClusterHaveUserData?: ReturnType; +} + +describe('GET /internal/security_oss/display_insecure_cluster_alert', () => { + let server: SetupServerReturn['server']; + let httpSetup: SetupServerReturn['httpSetup']; + + const setupTestServer = async ({ + config = { showInsecureClusterWarning: true }, + displayModifier$ = new BehaviorSubject(true), + doesClusterHaveUserData = jest.fn().mockResolvedValue(true), + }: SetupOpts) => { + ({ server, httpSetup } = await setupServer(pluginId)); + + const router = httpSetup.createRouter('/'); + const log = loggingSystemMock.createLogger(); + + setupDisplayInsecureClusterAlertRoute({ + router, + log, + config$: of(config), + displayModifier$, + doesClusterHaveUserData, + }); + + await server.start(); + + return { + log, + }; + }; + + afterEach(async () => { + await server.stop(); + }); + + it('responds `false` if plugin is not configured to display alerts', async () => { + await setupTestServer({ + config: { showInsecureClusterWarning: false }, + }); + + await supertest(httpSetup.server.listener) + .get('/internal/security_oss/display_insecure_cluster_alert') + .expect(200, { displayAlert: false }); + }); + + it('responds `false` if cluster does not contain user data', async () => { + await setupTestServer({ + config: { showInsecureClusterWarning: true }, + doesClusterHaveUserData: jest.fn().mockResolvedValue(false), + }); + + await supertest(httpSetup.server.listener) + .get('/internal/security_oss/display_insecure_cluster_alert') + .expect(200, { displayAlert: false }); + }); + + it('responds `false` if displayModifier$ is set to false', async () => { + await setupTestServer({ + config: { showInsecureClusterWarning: true }, + doesClusterHaveUserData: jest.fn().mockResolvedValue(true), + displayModifier$: new BehaviorSubject(false), + }); + + await supertest(httpSetup.server.listener) + .get('/internal/security_oss/display_insecure_cluster_alert') + .expect(200, { displayAlert: false }); + }); + + it('responds `true` if cluster contains user data', async () => { + await setupTestServer({ + config: { showInsecureClusterWarning: true }, + doesClusterHaveUserData: jest.fn().mockResolvedValue(true), + }); + + await supertest(httpSetup.server.listener) + .get('/internal/security_oss/display_insecure_cluster_alert') + .expect(200, { displayAlert: true }); + }); + + it('responds to changing displayModifier$ values', async () => { + const displayModifier$ = new BehaviorSubject(true); + + await setupTestServer({ + config: { showInsecureClusterWarning: true }, + doesClusterHaveUserData: jest.fn().mockResolvedValue(true), + displayModifier$, + }); + + await supertest(httpSetup.server.listener) + .get('/internal/security_oss/display_insecure_cluster_alert') + .expect(200, { displayAlert: true }); + + displayModifier$.next(false); + + await supertest(httpSetup.server.listener) + .get('/internal/security_oss/display_insecure_cluster_alert') + .expect(200, { displayAlert: false }); + }); +}); diff --git a/tasks/function_test_groups.js b/tasks/function_test_groups.js index d60f3ae53eecc..ac41b3f36be0f 100644 --- a/tasks/function_test_groups.js +++ b/tasks/function_test_groups.js @@ -43,6 +43,8 @@ const getDefaultArgs = (tag) => { '--debug', '--config', 'test/new_visualize_flow/config.js', + '--config', + 'test/security_functional/config.ts', ]; }; diff --git a/test/common/config.js b/test/common/config.js index 6a62151b12814..dbbd75d1f9577 100644 --- a/test/common/config.js +++ b/test/common/config.js @@ -48,6 +48,7 @@ export default function () { `--elasticsearch.username=${kibanaServerTestUser.username}`, `--elasticsearch.password=${kibanaServerTestUser.password}`, `--home.disableWelcomeScreen=true`, + `--security.showInsecureClusterWarning=false`, '--telemetry.banner=false', '--telemetry.optIn=false', // These are *very* important to have them pointing to staging diff --git a/test/security_functional/config.ts b/test/security_functional/config.ts new file mode 100644 index 0000000000000..2a35d40678fd2 --- /dev/null +++ b/test/security_functional/config.ts @@ -0,0 +1,54 @@ +/* + * 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 path from 'path'; +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const functionalConfig = await readConfigFile(require.resolve('../functional/config')); + + return { + testFiles: [require.resolve('./index.ts')], + services: functionalConfig.get('services'), + pageObjects: functionalConfig.get('pageObjects'), + servers: functionalConfig.get('servers'), + esTestCluster: functionalConfig.get('esTestCluster'), + apps: {}, + esArchiver: { + directory: path.resolve(__dirname, '../functional/fixtures/es_archiver'), + }, + snapshots: { + directory: path.resolve(__dirname, 'snapshots'), + }, + junit: { + reportName: 'Security OSS Functional Tests', + }, + kbnTestServer: { + ...functionalConfig.get('kbnTestServer'), + serverArgs: [ + ...functionalConfig + .get('kbnTestServer.serverArgs') + .filter((arg: string) => !arg.startsWith('--security.showInsecureClusterWarning')), + '--security.showInsecureClusterWarning=true', + // Required to load new platform plugins via `--plugin-path` flag. + '--env.name=development', + ], + }, + }; +} diff --git a/test/security_functional/index.ts b/test/security_functional/index.ts new file mode 100644 index 0000000000000..8066a4eacf61a --- /dev/null +++ b/test/security_functional/index.ts @@ -0,0 +1,27 @@ +/* + * 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 { FtrProviderContext } from '../functional/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ loadTestFile }: FtrProviderContext) { + describe('Security OSS', function () { + this.tags(['skipCloud', 'ciGroup2']); + loadTestFile(require.resolve('./insecure_cluster_warning')); + }); +} diff --git a/test/security_functional/insecure_cluster_warning.ts b/test/security_functional/insecure_cluster_warning.ts new file mode 100644 index 0000000000000..03d9d248d6790 --- /dev/null +++ b/test/security_functional/insecure_cluster_warning.ts @@ -0,0 +1,87 @@ +/* + * 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 { FtrProviderContext } from 'test/functional/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const pageObjects = getPageObjects(['common']); + const testSubjects = getService('testSubjects'); + const browser = getService('browser'); + const esArchiver = getService('esArchiver'); + + describe('Insecure Cluster Warning', () => { + before(async () => { + await pageObjects.common.navigateToApp('home'); + await browser.setLocalStorageItem('insecureClusterWarningVisibility', ''); + // starting without user data + await esArchiver.unload('hamlet'); + }); + + after(async () => { + await esArchiver.unload('hamlet'); + }); + + describe('without user data', () => { + before(async () => { + await browser.setLocalStorageItem('insecureClusterWarningVisibility', ''); + await esArchiver.unload('hamlet'); + }); + + it('should not warn when the cluster contains no user data', async () => { + await browser.setLocalStorageItem( + 'insecureClusterWarningVisibility', + JSON.stringify({ show: false }) + ); + await pageObjects.common.navigateToApp('home'); + await testSubjects.missingOrFail('insecureClusterDefaultAlertText'); + }); + }); + + describe('with user data', () => { + before(async () => { + await pageObjects.common.navigateToApp('home'); + await browser.setLocalStorageItem('insecureClusterWarningVisibility', ''); + await esArchiver.load('hamlet'); + }); + + after(async () => { + await esArchiver.unload('hamlet'); + }); + + it('should warn about an insecure cluster, and hide when dismissed', async () => { + await pageObjects.common.navigateToApp('home'); + await testSubjects.existOrFail('insecureClusterDefaultAlertText'); + + await testSubjects.click('defaultDismissAlertButton'); + + await testSubjects.missingOrFail('insecureClusterDefaultAlertText'); + }); + + it('should not warn when local storage is configured to hide', async () => { + await browser.setLocalStorageItem( + 'insecureClusterWarningVisibility', + JSON.stringify({ show: false }) + ); + await pageObjects.common.navigateToApp('home'); + await testSubjects.missingOrFail('insecureClusterDefaultAlertText'); + }); + }); + }); +} diff --git a/x-pack/plugins/security/kibana.json b/x-pack/plugins/security/kibana.json index 40d7e293eaf66..40629dbe4f3b3 100644 --- a/x-pack/plugins/security/kibana.json +++ b/x-pack/plugins/security/kibana.json @@ -3,7 +3,7 @@ "version": "8.0.0", "kibanaVersion": "kibana", "configPath": ["xpack", "security"], - "requiredPlugins": ["data", "features", "licensing", "taskManager"], + "requiredPlugins": ["data", "features", "licensing", "taskManager", "securityOss"], "optionalPlugins": ["home", "management", "usageCollection"], "server": true, "ui": true, diff --git a/x-pack/plugins/security/public/plugin.test.tsx b/x-pack/plugins/security/public/plugin.test.tsx index fb8034da11731..d86d4812af5e3 100644 --- a/x-pack/plugins/security/public/plugin.test.tsx +++ b/x-pack/plugins/security/public/plugin.test.tsx @@ -8,6 +8,7 @@ import { Observable } from 'rxjs'; import BroadcastChannel from 'broadcast-channel'; import { CoreSetup } from 'src/core/public'; import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; +import { mockSecurityOssPlugin } from '../../../../src/plugins/security_oss/public/mocks'; import { SessionTimeout } from './session'; import { PluginStartDependencies, SecurityPlugin } from './plugin'; @@ -35,6 +36,7 @@ describe('Security Plugin', () => { >, { licensing: licensingMock.createSetup(), + securityOss: mockSecurityOssPlugin.createSetup(), } ) ).toEqual({ @@ -61,6 +63,7 @@ describe('Security Plugin', () => { plugin.setup(coreSetupMock as CoreSetup, { licensing: licensingMock.createSetup(), + securityOss: mockSecurityOssPlugin.createSetup(), management: managementSetupMock, }); @@ -85,11 +88,12 @@ describe('Security Plugin', () => { const plugin = new SecurityPlugin(coreMock.createPluginInitializerContext()); plugin.setup( coreMock.createSetup({ basePath: '/some-base-path' }) as CoreSetup, - { licensing: licensingMock.createSetup() } + { licensing: licensingMock.createSetup(), securityOss: mockSecurityOssPlugin.createSetup() } ); expect( plugin.start(coreMock.createStart({ basePath: '/some-base-path' }), { + securityOss: mockSecurityOssPlugin.createStart(), data: {} as DataPublicPluginStart, features: {} as FeaturesPluginStart, }) @@ -110,12 +114,14 @@ describe('Security Plugin', () => { coreMock.createSetup({ basePath: '/some-base-path' }) as CoreSetup, { licensing: licensingMock.createSetup(), + securityOss: mockSecurityOssPlugin.createSetup(), management: managementSetupMock, } ); const coreStart = coreMock.createStart({ basePath: '/some-base-path' }); plugin.start(coreStart, { + securityOss: mockSecurityOssPlugin.createStart(), data: {} as DataPublicPluginStart, features: {} as FeaturesPluginStart, management: managementStartMock, @@ -130,7 +136,7 @@ describe('Security Plugin', () => { const plugin = new SecurityPlugin(coreMock.createPluginInitializerContext()); plugin.setup( coreMock.createSetup({ basePath: '/some-base-path' }) as CoreSetup, - { licensing: licensingMock.createSetup() } + { licensing: licensingMock.createSetup(), securityOss: mockSecurityOssPlugin.createSetup() } ); expect(() => plugin.stop()).not.toThrow(); @@ -141,10 +147,11 @@ describe('Security Plugin', () => { plugin.setup( coreMock.createSetup({ basePath: '/some-base-path' }) as CoreSetup, - { licensing: licensingMock.createSetup() } + { licensing: licensingMock.createSetup(), securityOss: mockSecurityOssPlugin.createSetup() } ); plugin.start(coreMock.createStart({ basePath: '/some-base-path' }), { + securityOss: mockSecurityOssPlugin.createStart(), data: {} as DataPublicPluginStart, features: {} as FeaturesPluginStart, }); diff --git a/x-pack/plugins/security/public/plugin.tsx b/x-pack/plugins/security/public/plugin.tsx index f5770ae2bc35c..87bcc96d1f9d4 100644 --- a/x-pack/plugins/security/public/plugin.tsx +++ b/x-pack/plugins/security/public/plugin.tsx @@ -5,6 +5,7 @@ */ import { i18n } from '@kbn/i18n'; +import { SecurityOssPluginSetup, SecurityOssPluginStart } from 'src/plugins/security_oss/public'; import { CoreSetup, CoreStart, @@ -32,9 +33,11 @@ import { AuthenticationService, AuthenticationServiceSetup } from './authenticat import { ConfigType } from './config'; import { ManagementService } from './management'; import { accountManagementApp } from './account_management'; +import { SecurityCheckupService } from './security_checkup'; export interface PluginSetupDependencies { licensing: LicensingPluginSetup; + securityOss: SecurityOssPluginSetup; home?: HomePublicPluginSetup; management?: ManagementSetup; } @@ -42,6 +45,7 @@ export interface PluginSetupDependencies { export interface PluginStartDependencies { data: DataPublicPluginStart; features: FeaturesPluginStart; + securityOss: SecurityOssPluginStart; management?: ManagementStart; } @@ -58,6 +62,7 @@ export class SecurityPlugin private readonly navControlService = new SecurityNavControlService(); private readonly securityLicenseService = new SecurityLicenseService(); private readonly managementService = new ManagementService(); + private readonly securityCheckupService = new SecurityCheckupService(); private authc!: AuthenticationServiceSetup; private readonly config: ConfigType; @@ -67,7 +72,7 @@ export class SecurityPlugin public setup( core: CoreSetup, - { home, licensing, management }: PluginSetupDependencies + { home, licensing, management, securityOss }: PluginSetupDependencies ) { const { http, notifications } = core; const { anonymousPaths } = http; @@ -82,6 +87,8 @@ export class SecurityPlugin const { license } = this.securityLicenseService.setup({ license$: licensing.license$ }); + this.securityCheckupService.setup({ securityOssSetup: securityOss }); + this.authc = this.authenticationService.setup({ application: core.application, fatalErrors: core.fatalErrors, @@ -137,9 +144,10 @@ export class SecurityPlugin }; } - public start(core: CoreStart, { management }: PluginStartDependencies) { + public start(core: CoreStart, { management, securityOss }: PluginStartDependencies) { this.sessionTimeout.start(); this.navControlService.start({ core }); + this.securityCheckupService.start({ securityOssStart: securityOss }); if (management) { this.managementService.start({ capabilities: core.application.capabilities }); } @@ -150,6 +158,7 @@ export class SecurityPlugin this.navControlService.stop(); this.securityLicenseService.stop(); this.managementService.stop(); + this.securityCheckupService.stop(); } } diff --git a/x-pack/plugins/security/public/security_checkup/components/index.ts b/x-pack/plugins/security/public/security_checkup/components/index.ts new file mode 100644 index 0000000000000..685d0fe67db74 --- /dev/null +++ b/x-pack/plugins/security/public/security_checkup/components/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 { insecureClusterAlertTitle, insecureClusterAlertText } from './insecure_cluster_alert'; diff --git a/x-pack/plugins/security/public/security_checkup/components/insecure_cluster_alert.tsx b/x-pack/plugins/security/public/security_checkup/components/insecure_cluster_alert.tsx new file mode 100644 index 0000000000000..6ba06e0cc4770 --- /dev/null +++ b/x-pack/plugins/security/public/security_checkup/components/insecure_cluster_alert.tsx @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { I18nProvider, FormattedMessage } from '@kbn/i18n/react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { MountPoint } from 'kibana/public'; +import { + EuiCheckbox, + EuiText, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiButton, +} from '@elastic/eui'; + +export const insecureClusterAlertTitle = i18n.translate( + 'xpack.security.checkup.insecureClusterTitle', + { defaultMessage: 'Please secure your installation' } +); + +export const insecureClusterAlertText = (onDismiss: (persist: boolean) => void) => + ((e) => { + const AlertText = () => { + const [persist, setPersist] = useState(false); + + return ( + +
+ + + + + setPersist(changeEvent.target.checked)} + label={i18n.translate('xpack.security.checkup.dontShowAgain', { + defaultMessage: `Don't show again`, + })} + /> + + + + + {i18n.translate('xpack.security.checkup.enableButtonText', { + defaultMessage: `Enable security`, + })} + + + + onDismiss(persist)} + data-test-subj="dismissAlertButton" + > + {i18n.translate('xpack.security.checkup.dismissButtonText', { + defaultMessage: `Dismiss`, + })} + + + +
+
+ ); + }; + + render(, e); + + return () => unmountComponentAtNode(e); + }) as MountPoint; diff --git a/x-pack/plugins/security/public/security_checkup/index.ts b/x-pack/plugins/security/public/security_checkup/index.ts new file mode 100644 index 0000000000000..691a99a5c6fc0 --- /dev/null +++ b/x-pack/plugins/security/public/security_checkup/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 { SecurityCheckupService } from './security_checkup_service'; diff --git a/x-pack/plugins/security/public/security_checkup/security_checkup_service.test.ts b/x-pack/plugins/security/public/security_checkup/security_checkup_service.test.ts new file mode 100644 index 0000000000000..3709f52d29ffb --- /dev/null +++ b/x-pack/plugins/security/public/security_checkup/security_checkup_service.test.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. + */ + +import { mockSecurityOssPlugin } from '../../../../../src/plugins/security_oss/public/mocks'; +import { insecureClusterAlertTitle } from './components'; +import { SecurityCheckupService } from './security_checkup_service'; + +let mockOnDismiss = jest.fn(); + +jest.mock('./components', () => { + return { + insecureClusterAlertTitle: 'mock insecure cluster title', + insecureClusterAlertText: (onDismiss: any) => { + mockOnDismiss = onDismiss; + return 'mock insecure cluster text'; + }, + }; +}); + +describe('SecurityCheckupService', () => { + describe('#setup', () => { + it('configures the alert title and text for the default distribution', async () => { + const securityOssSetup = mockSecurityOssPlugin.createSetup(); + const service = new SecurityCheckupService(); + service.setup({ securityOssSetup }); + + expect(securityOssSetup.insecureCluster.setAlertTitle).toHaveBeenCalledWith( + insecureClusterAlertTitle + ); + + expect(securityOssSetup.insecureCluster.setAlertText).toHaveBeenCalledWith( + 'mock insecure cluster text' + ); + }); + }); + describe('#start', () => { + it('onDismiss triggers hiding of the alert', async () => { + const securityOssSetup = mockSecurityOssPlugin.createSetup(); + const securityOssStart = mockSecurityOssPlugin.createStart(); + const service = new SecurityCheckupService(); + service.setup({ securityOssSetup }); + service.start({ securityOssStart }); + + expect(securityOssStart.insecureCluster.hideAlert).toHaveBeenCalledTimes(0); + + mockOnDismiss(); + + expect(securityOssStart.insecureCluster.hideAlert).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/security/public/security_checkup/security_checkup_service.tsx b/x-pack/plugins/security/public/security_checkup/security_checkup_service.tsx new file mode 100644 index 0000000000000..899a74083656b --- /dev/null +++ b/x-pack/plugins/security/public/security_checkup/security_checkup_service.tsx @@ -0,0 +1,42 @@ +/* + * 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 { + SecurityOssPluginSetup, + SecurityOssPluginStart, +} from '../../../../../src/plugins/security_oss/public'; +import { insecureClusterAlertTitle, insecureClusterAlertText } from './components'; + +interface SetupDeps { + securityOssSetup: SecurityOssPluginSetup; +} + +interface StartDeps { + securityOssStart: SecurityOssPluginStart; +} + +export class SecurityCheckupService { + private securityOssStart?: SecurityOssPluginStart; + + public setup({ securityOssSetup }: SetupDeps) { + securityOssSetup.insecureCluster.setAlertTitle(insecureClusterAlertTitle); + securityOssSetup.insecureCluster.setAlertText( + insecureClusterAlertText((persist: boolean) => this.onDismiss(persist)) + ); + } + + public start({ securityOssStart }: StartDeps) { + this.securityOssStart = securityOssStart; + } + + private onDismiss(persist: boolean) { + if (this.securityOssStart) { + this.securityOssStart.insecureCluster.hideAlert(persist); + } + } + + public stop() {} +} diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 69b55fcb3d0a4..7d44160c52e53 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -9,6 +9,7 @@ import { first, map } from 'rxjs/operators'; import { TypeOf } from '@kbn/config-schema'; import { deepFreeze } from '@kbn/std'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { SecurityOssPluginSetup } from 'src/plugins/security_oss/server'; import { CoreSetup, CoreStart, @@ -84,6 +85,7 @@ export interface PluginSetupDependencies { licensing: LicensingPluginSetup; taskManager: TaskManagerSetupContract; usageCollection?: UsageCollectionSetup; + securityOss?: SecurityOssPluginSetup; } export interface PluginStartDependencies { @@ -133,7 +135,7 @@ export class Plugin { public async setup( core: CoreSetup, - { features, licensing, taskManager, usageCollection }: PluginSetupDependencies + { features, licensing, taskManager, usageCollection, securityOss }: PluginSetupDependencies ) { const [config, legacyConfig] = await combineLatest([ this.initializerContext.config.create>().pipe( @@ -153,6 +155,13 @@ export class Plugin { license$: licensing.license$, }); + if (securityOss) { + license.features$.subscribe(({ allowRbac }) => { + const showInsecureClusterWarning = !allowRbac; + securityOss.showInsecureClusterWarning$.next(showInsecureClusterWarning); + }); + } + securityFeatures.forEach((securityFeature) => features.registerElasticsearchFeature(securityFeature) );