diff --git a/x-pack/plugins/cross_cluster_replication/public/app/app.js b/x-pack/plugins/cross_cluster_replication/public/app/app.js index 0663b6fae8c35..985306ddd67b9 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/app.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/app.js @@ -7,9 +7,25 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { Route, Switch, Redirect } from 'react-router-dom'; +import chrome from 'ui/chrome'; +import { fatalError } from 'ui/notify'; +import { i18n } from '@kbn/i18n'; +import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; + +import { + EuiEmptyPrompt, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiPageContent, + EuiTitle, +} from '@elastic/eui'; -import routing from './services/routing'; import { BASE_PATH } from '../../common/constants'; +import { SectionUnauthorized, SectionError } from './components'; +import routing from './services/routing'; +import { isAvailable, isActive, getReason } from './services/license'; +import { loadPermissions } from './services/api'; import { CrossClusterReplicationHome, @@ -19,47 +35,181 @@ import { FollowerIndexEdit, } from './sections'; -export class App extends Component { - static contextTypes = { - router: PropTypes.shape({ - history: PropTypes.shape({ - push: PropTypes.func.isRequired, - createHref: PropTypes.func.isRequired +export const App = injectI18n( + class extends Component { + static contextTypes = { + router: PropTypes.shape({ + history: PropTypes.shape({ + push: PropTypes.func.isRequired, + createHref: PropTypes.func.isRequired + }).isRequired }).isRequired - }).isRequired - } + } - constructor(...args) { - super(...args); - this.registerRouter(); - } + constructor(...args) { + super(...args); + this.registerRouter(); - componentWillMount() { - routing.userHasLeftApp = false; - } + this.state = { + isFetchingPermissions: false, + fetchPermissionError: undefined, + hasPermission: false, + missingPermissions: [], + }; + } - componentWillUnmount() { - routing.userHasLeftApp = true; - } + componentWillMount() { + routing.userHasLeftApp = false; + } - registerRouter() { - const { router } = this.context; - routing.reactRouter = router; - } + componentDidMount() { + this.checkPermissions(); + } - render() { - return ( -
- - - - - - - - -
- ); - } -} + componentWillUnmount() { + routing.userHasLeftApp = true; + } + + async checkPermissions() { + this.setState({ + isFetchingPermissions: true, + }); + try { + const { hasPermission, missingPermissions } = await loadPermissions(); + + this.setState({ + isFetchingPermissions: false, + hasPermission, + missingPermissions, + }); + } catch (error) { + // Expect an error in the shape provided by Angular's $http service. + if (error && error.data) { + return this.setState({ + isFetchingPermissions: false, + fetchPermissionError: error, + }); + } + + // This error isn't an HTTP error, so let the fatal error screen tell the user something + // unexpected happened. + fatalError(error, i18n.translate('xpack.crossClusterReplication.app.checkPermissionsFatalErrorTitle', { + defaultMessage: 'Cross Cluster Replication app', + })); + } + } + + registerRouter() { + const { router } = this.context; + routing.reactRouter = router; + } + + render() { + const { + isFetchingPermissions, + fetchPermissionError, + hasPermission, + missingPermissions, + } = this.state; + + if (!isAvailable() || !isActive()) { + return ( + + )} + > + {getReason()} + {' '} + + + + + ); + } + + if (isFetchingPermissions) { + return ( + + + + + + + + +

+ +

+
+
+
+
+ ); + } + + if (fetchPermissionError) { + return ( + + )} + error={fetchPermissionError} + /> + ); + } + + if (!hasPermission) { + return ( + + + + } + body={ +

+ +

} + /> +
+ ); + } + + return ( +
+ + + + + + + + +
+ ); + } + } +); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js index 0017e30e3cb42..d4d60cf33bbf5 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js @@ -190,7 +190,7 @@ export const FollowerIndexForm = injectI18n( if (error && error.data) { // All validation does is check for a name collision, so we can just let the user attempt // to save the follower index and get an error back from the API. - this.setState({ + return this.setState({ isValidatingIndexName: false, }); } @@ -198,7 +198,7 @@ export const FollowerIndexForm = injectI18n( // This error isn't an HTTP error, so let the fatal error screen tell the user something // unexpected happened. fatalError(error, i18n.translate('xpack.crossClusterReplication.followerIndexForm.indexNameValidationFatalErrorTitle', { - defaultMessage: 'Follower Index Forn index name validation', + defaultMessage: 'Follower Index Form index name validation', })); } }; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/section_unauthorized.js b/x-pack/plugins/cross_cluster_replication/public/app/components/section_unauthorized.js index 958065e87424e..90737613479d4 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/section_unauthorized.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/section_unauthorized.js @@ -5,15 +5,10 @@ */ import React, { Fragment } from 'react'; -import { injectI18n } from '@kbn/i18n/react'; import { EuiCallOut } from '@elastic/eui'; -export function SectionUnauthorizedUI({ intl, children }) { - const title = intl.formatMessage({ - id: 'xpack.crossClusterReplication.remoteClusterList.noPermissionTitle', - defaultMessage: 'Permission error', - }); +export function SectionUnauthorized({ title, children }) { return ( ); } - -export const SectionUnauthorized = injectI18n(SectionUnauthorizedUI); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/home.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/home.js index 3181b5aebf2bf..89b691ecb1c61 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/home.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/home.js @@ -10,7 +10,6 @@ import { Route, Switch } from 'react-router-dom'; import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; import chrome from 'ui/chrome'; import { MANAGEMENT_BREADCRUMB } from 'ui/management'; -import { BASE_PATH } from '../../../../common/constants'; import { EuiButton, @@ -25,6 +24,7 @@ import { EuiTitle, } from '@elastic/eui'; +import { BASE_PATH } from '../../../../common/constants'; import { listBreadcrumb } from '../../services/breadcrumbs'; import routing from '../../services/routing'; import { AutoFollowPatternList } from './auto_follow_pattern_list'; @@ -36,8 +36,8 @@ export const CrossClusterReplicationHome = injectI18n( static propTypes = { autoFollowPatterns: PropTypes.array, isAutoFollowApiAuthorized: PropTypes.bool, - followerIndices: PropTypes.array, isFollowerIndexApiAuthorized: PropTypes.bool, + followerIndices: PropTypes.array, } state = { @@ -77,16 +77,11 @@ export const CrossClusterReplicationHome = injectI18n( routing.navigate(`/${section}`); } - getHeaderSection() { - if(this.state.activeSection === 'follower_indices') { - const { isFollowerIndexApiAuthorized, followerIndices } = this.props; - - - // We want to show the title when the user isn't authorized. - if (isFollowerIndexApiAuthorized && !followerIndices.length) { - return null; - } + renderHeaderSection() { + const { followerIndices, autoFollowPatterns } = this.props; + // If we're rendering the empty prompt, we don't want to render the header. + if (this.state.activeSection === 'follower_indices' && followerIndices.length > 0) { return ( @@ -102,32 +97,26 @@ export const CrossClusterReplicationHome = injectI18n( - {isFollowerIndexApiAuthorized && ( - - - - )} + + + ); - } else { - const { isAutoFollowApiAuthorized, autoFollowPatterns } = this.props; - - // We want to show the title when the user isn't authorized. - if (isAutoFollowApiAuthorized && !autoFollowPatterns.length) { - return null; - } + } + // If we're rendering the empty prompt, we don't want to render the header. + if (autoFollowPatterns.length > 0) { return ( @@ -144,18 +133,16 @@ export const CrossClusterReplicationHome = injectI18n( - {isAutoFollowApiAuthorized && ( - - - - )} + + + @@ -165,18 +152,51 @@ export const CrossClusterReplicationHome = injectI18n( } } - getUnauthorizedSection() { - const { isAutoFollowApiAuthorized } = this.props; - if (!isAutoFollowApiAuthorized) { + renderContent() { + const { isAutoFollowApiAuthorized, isFollowerIndexApiAuthorized } = this.props; + + if (!isAutoFollowApiAuthorized || !isFollowerIndexApiAuthorized) { return ( - + + )} + > ); } + + return ( + + + {this.tabs.map(tab => ( + this.onSectionChange(tab.id)} + isSelected={tab.id === this.state.activeSection} + key={tab.id} + > + {tab.name} + + ))} + + + + + {this.renderHeaderSection()} + + + + + + + ); } render() { @@ -194,27 +214,7 @@ export const CrossClusterReplicationHome = injectI18n( - - {this.tabs.map(tab => ( - this.onSectionChange(tab.id)} - isSelected={tab.id === this.state.activeSection} - key={tab.id} - > - {tab.name} - - ))} - - - - - {this.getHeaderSection()} - {this.getUnauthorizedSection()} - - - - - + {this.renderContent()} ); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/api.js b/x-pack/plugins/cross_cluster_replication/public/app/services/api.js index d122b1fbf17ff..d1e1386003ed2 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/services/api.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/services/api.js @@ -95,7 +95,7 @@ export const updateFollowerIndex = (id, followerIndex) => ( /* Stats */ export const loadAutoFollowStats = () => ( - httpClient.get(`${apiPrefixIndexManagement}/stats/auto-follow`).then(extractData) + httpClient.get(`${apiPrefixIndexManagement}/stats/auto_follow`).then(extractData) ); /* Indices */ @@ -112,3 +112,7 @@ export const loadIndices = () => { return extractData(response); }); }; + +export const loadPermissions = () => ( + httpClient.get(`${apiPrefix}/permissions`).then(extractData) +); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/license.js b/x-pack/plugins/cross_cluster_replication/public/app/services/license.js new file mode 100644 index 0000000000000..c61a363472149 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/services/license.js @@ -0,0 +1,15 @@ +/* + * 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 let isAvailable; +export let isActive; +export let getReason; + +export function setLicense(isAvailableCallback, isActiveCallback, getReasonCallback) { + isAvailable = isAvailableCallback; + isActive = isActiveCallback; + getReason = getReasonCallback; +} diff --git a/x-pack/plugins/cross_cluster_replication/public/index.js b/x-pack/plugins/cross_cluster_replication/public/index.js index d9e036d2dfbfb..e92c44da34474 100644 --- a/x-pack/plugins/cross_cluster_replication/public/index.js +++ b/x-pack/plugins/cross_cluster_replication/public/index.js @@ -4,5 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -import './register_ccr_section'; import './register_routes'; diff --git a/x-pack/plugins/cross_cluster_replication/public/register_ccr_section.js b/x-pack/plugins/cross_cluster_replication/public/register_ccr_section.js deleted file mode 100644 index 4383e29e5548d..0000000000000 --- a/x-pack/plugins/cross_cluster_replication/public/register_ccr_section.js +++ /dev/null @@ -1,21 +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 { management } from 'ui/management'; -import { i18n } from '@kbn/i18n'; -import chrome from 'ui/chrome'; -import { BASE_PATH } from '../common/constants'; - -if (chrome.getInjected('ccrUiEnabled')) { - const esSection = management.getSection('elasticsearch'); - - esSection.register('ccr', { - visible: true, - display: i18n.translate('xpack.crossClusterReplication.appTitle', { defaultMessage: 'Cross Cluster Replication' }), - order: 3, - url: `#${BASE_PATH}` - }); -} diff --git a/x-pack/plugins/cross_cluster_replication/public/register_routes.js b/x-pack/plugins/cross_cluster_replication/public/register_routes.js index 6ab2229f0e6eb..d78e9fd12cb84 100644 --- a/x-pack/plugins/cross_cluster_replication/public/register_routes.js +++ b/x-pack/plugins/cross_cluster_replication/public/register_routes.js @@ -4,16 +4,29 @@ * you may not use this file except in compliance with the Elastic License. */ -import routes from 'ui/routes'; import { unmountComponentAtNode } from 'react-dom'; import chrome from 'ui/chrome'; +import { management } from 'ui/management'; +import routes from 'ui/routes'; +import { XPackInfoProvider } from 'plugins/xpack_main/services/xpack_info'; +import { i18n } from '@kbn/i18n'; import template from './main.html'; -import { BASE_PATH } from '../common/constants/base_path'; +import { BASE_PATH } from '../common/constants'; import { renderReact } from './app'; import { setHttpClient } from './app/services/api'; +import { setLicense } from './app/services/license'; if (chrome.getInjected('ccrUiEnabled')) { + const esSection = management.getSection('elasticsearch'); + + esSection.register('ccr', { + visible: true, + display: i18n.translate('xpack.crossClusterReplication.appTitle', { defaultMessage: 'Cross Cluster Replication' }), + order: 3, + url: `#${BASE_PATH}` + }); + let elem; const CCR_REACT_ROOT = 'ccrReactRoot'; @@ -21,16 +34,27 @@ if (chrome.getInjected('ccrUiEnabled')) { const unmountReactApp = () => elem && unmountComponentAtNode(elem); routes.when(`${BASE_PATH}/:section?/:subsection?/:view?/:id?`, { - template: template, + template, + resolve: { + license(Private) { + const xpackInfo = Private(XPackInfoProvider); + return { + isAvailable: () => xpackInfo.get('features.crossClusterReplication.isAvailable'), + isActive: () => xpackInfo.get('features.crossClusterReplication.isActive'), + getReason: () => xpackInfo.get('features.crossClusterReplication.message'), + }; + } + }, controllerAs: 'ccr', controller: class CrossClusterReplicationController { constructor($scope, $route, $http, $q) { - /** - * React-router's does not play well with the angular router. It will cause this controller - * to re-execute without the $destroy handler being called. This means that the app will be mounted twice - * creating a memory leak when leaving (only 1 app will be unmounted). - * To avoid this, we unmount the React app each time we enter the controller. - */ + const { license: { isAvailable, isActive, getReason } } = $route.current.locals; + setLicense(isAvailable, isActive, getReason); + + // React-router's does not play well with the angular router. It will cause this controller + // to re-execute without the $destroy handler being called. This means that the app will be mounted twice + // creating a memory leak when leaving (only 1 app will be unmounted). + // To avoid this, we unmount the React app each time we enter the controller. unmountReactApp(); // NOTE: We depend upon Angular's $http service because it's decorated with interceptors, diff --git a/x-pack/plugins/cross_cluster_replication/server/client/elasticsearch_ccr.js b/x-pack/plugins/cross_cluster_replication/server/client/elasticsearch_ccr.js index 716e4954c69b1..4ee39cb08c8d8 100644 --- a/x-pack/plugins/cross_cluster_replication/server/client/elasticsearch_ccr.js +++ b/x-pack/plugins/cross_cluster_replication/server/client/elasticsearch_ccr.js @@ -10,6 +10,16 @@ export const elasticsearchJsPlugin = (Client, config, components) => { Client.prototype.ccr = components.clientAction.namespaceFactory(); const ccr = Client.prototype.ccr.prototype; + ccr.permissions = ca({ + urls: [ + { + fmt: '/_security/user/_has_privileges', + } + ], + needBody: true, + method: 'POST' + }); + ccr.autoFollowPatterns = ca({ urls: [ { diff --git a/x-pack/plugins/cross_cluster_replication/server/lib/check_license/check_license.js b/x-pack/plugins/cross_cluster_replication/server/lib/check_license/check_license.js index 35e5e3783e628..fb99de8ab5d97 100644 --- a/x-pack/plugins/cross_cluster_replication/server/lib/check_license/check_license.js +++ b/x-pack/plugins/cross_cluster_replication/server/lib/check_license/check_license.js @@ -26,7 +26,7 @@ export function checkLicense(xpackLicenseInfo) { }; } - const VALID_LICENSE_MODES = ['trial', 'basic', 'standard', 'gold', 'platinum']; + const VALID_LICENSE_MODES = [ 'trial', 'platinum' ]; const isLicenseModeValid = xpackLicenseInfo.license.isOneOf(VALID_LICENSE_MODES); const isLicenseActive = xpackLicenseInfo.license.isActive(); @@ -36,7 +36,7 @@ export function checkLicense(xpackLicenseInfo) { if (!isLicenseModeValid) { return { isAvailable: false, - showLinks: false, + isActive: false, message: i18n.translate( 'xpack.crossClusterReplication.checkLicense.errorUnsupportedMessage', { @@ -50,9 +50,8 @@ export function checkLicense(xpackLicenseInfo) { // License is valid but not active if (!isLicenseActive) { return { - isAvailable: false, - showLinks: true, - enableLinks: false, + isAvailable: true, + isActive: false, message: i18n.translate( 'xpack.crossClusterReplication.checkLicense.errorExpiredMessage', { @@ -66,7 +65,6 @@ export function checkLicense(xpackLicenseInfo) { // License is valid and active return { isAvailable: true, - showLinks: true, - enableLinks: true, + isActive: true, }; } diff --git a/x-pack/plugins/cross_cluster_replication/server/routes/api/ccr.js b/x-pack/plugins/cross_cluster_replication/server/routes/api/ccr.js index 781f4d6ec6cd5..b17634585c9cf 100644 --- a/x-pack/plugins/cross_cluster_replication/server/routes/api/ccr.js +++ b/x-pack/plugins/cross_cluster_replication/server/routes/api/ccr.js @@ -50,7 +50,7 @@ export const registerCcrRoutes = (server) => { * Returns Auto-follow stats */ server.route({ - path: `${API_BASE_PATH}/stats/auto-follow`, + path: `${API_BASE_PATH}/stats/auto_follow`, method: 'GET', config: { pre: [ licensePreRouting ] @@ -60,4 +60,46 @@ export const registerCcrRoutes = (server) => { return autoFollow; }, }); + + /** + * Returns whether the user has CCR permissions + */ + server.route({ + path: `${API_BASE_PATH}/permissions`, + method: 'GET', + config: { + pre: [ licensePreRouting ] + }, + handler: async (request) => { + const callWithRequest = callWithRequestFactory(server, request); + + try { + const { + has_all_requested: hasPermission, + cluster, + } = await callWithRequest('ccr.permissions', { + body: { + cluster: ['manage', 'manage_ccr'], + }, + }); + + const missingPermissions = Object.keys(cluster).reduce((permissions, permissionName) => { + if (!cluster[permissionName]) { + permissions.push(permissionName); + return permissions; + } + }, []); + + return { + hasPermission, + missingPermissions, + }; + } catch(err) { + if (isEsError(err)) { + throw wrapEsError(err); + } + throw wrapUnknownError(err); + } + }, + }); }; diff --git a/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/__snapshots__/remote_cluster_form.test.js.snap b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/__snapshots__/remote_cluster_form.test.js.snap index a2833461674a4..18c015dc3c15a 100644 --- a/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/__snapshots__/remote_cluster_form.test.js.snap +++ b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/__snapshots__/remote_cluster_form.test.js.snap @@ -164,7 +164,7 @@ Array [ class="euiFormHelpText euiFormRow__text" id="my-id-help" > - An IP address or host name, followed by the port. + An IP address or host name, followed by the transport port of the remote cluster. diff --git a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/remote_cluster_table/__snapshots__/remote_cluster_table.test.js.snap b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/remote_cluster_table/__snapshots__/remote_cluster_table.test.js.snap index 1328265be20df..0a46eb8ff18aa 100644 --- a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/remote_cluster_table/__snapshots__/remote_cluster_table.test.js.snap +++ b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/remote_cluster_table/__snapshots__/remote_cluster_table.test.js.snap @@ -65,27 +65,58 @@ exports[`RemoteClusterTable renders a row for a default remote cluster 1`] = `
- - - +
+ + + +
+
+
+ Not connected +
+
+
-
- Not connected -
+ + + +
@@ -281,27 +312,58 @@ exports[`RemoteClusterTable renders a row for a remote cluster defined in elasti
- - - +
+ + + +
+
+
+ Not connected +
+
+
-
- Not connected -
+ + + +
diff --git a/x-pack/plugins/remote_clusters/server/lib/check_license/check_license.js b/x-pack/plugins/remote_clusters/server/lib/check_license/check_license.js index c589cbd0c8965..973ece733eedb 100644 --- a/x-pack/plugins/remote_clusters/server/lib/check_license/check_license.js +++ b/x-pack/plugins/remote_clusters/server/lib/check_license/check_license.js @@ -26,6 +26,7 @@ export function checkLicense(xpackLicenseInfo) { }; } + // Remote Clusters are used in both CCS and CCR, and CCS is available for all licenses. const VALID_LICENSE_MODES = [ 'trial', 'basic', diff --git a/x-pack/plugins/xpack_main/public/services/xpack_info.js b/x-pack/plugins/xpack_main/public/services/xpack_info.js index 8c0d972dad2f4..f02e0be8f9ffe 100644 --- a/x-pack/plugins/xpack_main/public/services/xpack_info.js +++ b/x-pack/plugins/xpack_main/public/services/xpack_info.js @@ -27,7 +27,8 @@ export function XPackInfoProvider($window, $injector, Private) { }; setAll = (updatedXPackInfo) => { - $window.sessionStorage.setItem(XPACK_INFO_KEY, JSON.stringify(updatedXPackInfo)); + const camelCasedXPackInfo = convertKeysToCamelCaseDeep(updatedXPackInfo); + $window.sessionStorage.setItem(XPACK_INFO_KEY, JSON.stringify(camelCasedXPackInfo)); }; clear = () => { @@ -52,7 +53,7 @@ export function XPackInfoProvider($window, $injector, Private) { throw err; }) .then((xpackInfoResponse) => { - this.setAll(convertKeysToCamelCaseDeep(xpackInfoResponse.data)); + this.setAll(xpackInfoResponse.data); xpackInfoSignature.set(xpackInfoResponse.headers('kbn-xpack-sig')); }) .finally(() => {