diff --git a/examples/controls_example/public/edit_example.tsx b/examples/controls_example/public/edit_example.tsx index f6297befa615ce..148867337fedde 100644 --- a/examples/controls_example/public/edit_example.tsx +++ b/examples/controls_example/public/edit_example.tsx @@ -133,7 +133,7 @@ export const EditExample = () => { iconType="plusInCircle" isDisabled={controlGroupAPI === undefined} onClick={() => { - controlGroupAPI!.openAddDataControlFlyout(controlInputTransform); + controlGroupAPI!.openAddDataControlFlyout({ controlInputTransform }); }} > Add control diff --git a/src/plugins/controls/public/control_group/editor/open_add_data_control_flyout.tsx b/src/plugins/controls/public/control_group/editor/open_add_data_control_flyout.tsx index bf601934324887..695eaa42e064db 100644 --- a/src/plugins/controls/public/control_group/editor/open_add_data_control_flyout.tsx +++ b/src/plugins/controls/public/control_group/editor/open_add_data_control_flyout.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { toMountPoint } from '@kbn/kibana-react-plugin/public'; +import { isErrorEmbeddable } from '@kbn/embeddable-plugin/public'; import { ControlGroupContainer, @@ -32,8 +33,12 @@ import { DataControlInput, OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL } from '.. export function openAddDataControlFlyout( this: ControlGroupContainer, - controlInputTransform?: ControlInputTransform + options?: { + controlInputTransform?: ControlInputTransform; + onSave?: (id: string) => void; + } ) { + const { controlInputTransform, onSave } = options || {}; const { overlays: { openFlyout, openConfirm }, controls: { getControlFactory }, @@ -71,7 +76,7 @@ export function openAddDataControlFlyout( updateTitle={(newTitle) => (controlInput.title = newTitle)} updateWidth={(defaultControlWidth) => this.updateInput({ defaultControlWidth })} updateGrow={(defaultControlGrow: boolean) => this.updateInput({ defaultControlGrow })} - onSave={(type) => { + onSave={async (type) => { this.closeAllFlyouts(); if (!type) { return; @@ -86,17 +91,28 @@ export function openAddDataControlFlyout( controlInput = controlInputTransform({ ...controlInput }, type); } - if (type === OPTIONS_LIST_CONTROL) { - this.addOptionsListControl(controlInput as AddOptionsListControlProps); - return; - } + let newControl; - if (type === RANGE_SLIDER_CONTROL) { - this.addRangeSliderControl(controlInput as AddRangeSliderControlProps); - return; + switch (type) { + case OPTIONS_LIST_CONTROL: + newControl = await this.addOptionsListControl( + controlInput as AddOptionsListControlProps + ); + break; + case RANGE_SLIDER_CONTROL: + newControl = await this.addRangeSliderControl( + controlInput as AddRangeSliderControlProps + ); + break; + default: + newControl = await this.addDataControlFromField( + controlInput as AddDataControlProps + ); } - this.addDataControlFromField(controlInput as AddDataControlProps); + if (onSave && !isErrorEmbeddable(newControl)) { + onSave(newControl.id); + } }} onCancel={onCancel} onTypeEditorChange={(partialInput) => diff --git a/src/plugins/dashboard/public/dashboard_actions/clone_panel_action.tsx b/src/plugins/dashboard/public/dashboard_actions/clone_panel_action.tsx index 482553e2f002fd..228db7138fd543 100644 --- a/src/plugins/dashboard/public/dashboard_actions/clone_panel_action.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/clone_panel_action.tsx @@ -95,6 +95,7 @@ export class ClonePanelAction implements Action { height: panelToClone.gridData.h, currentPanels: dashboard.getInput().panels, placeBesideId: panelToClone.explicitInput.id, + scrollToPanel: true, } as IPanelPlacementBesideArgs ); } diff --git a/src/plugins/dashboard/public/dashboard_actions/expand_panel_action.tsx b/src/plugins/dashboard/public/dashboard_actions/expand_panel_action.tsx index 0d3dd592dcc343..4e98a6dd310248 100644 --- a/src/plugins/dashboard/public/dashboard_actions/expand_panel_action.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/expand_panel_action.tsx @@ -64,5 +64,9 @@ export class ExpandPanelAction implements Action { } const newValue = isExpanded(embeddable) ? undefined : embeddable.id; (embeddable.parent as DashboardContainer).setExpandedPanelId(newValue); + + if (!newValue) { + (embeddable.parent as DashboardContainer).setScrollToPanelId(embeddable.id); + } } } diff --git a/src/plugins/dashboard/public/dashboard_actions/replace_panel_flyout.tsx b/src/plugins/dashboard/public/dashboard_actions/replace_panel_flyout.tsx index 4a2ac8f41d6a65..14067f0b6aa68f 100644 --- a/src/plugins/dashboard/public/dashboard_actions/replace_panel_flyout.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/replace_panel_flyout.tsx @@ -21,6 +21,7 @@ import { Toast } from '@kbn/core/public'; import { DashboardPanelState } from '../../common'; import { pluginServices } from '../services/plugin_services'; import { dashboardReplacePanelActionStrings } from './_dashboard_actions_strings'; +import { DashboardContainer } from '../dashboard_container'; interface Props { container: IContainer; @@ -82,6 +83,7 @@ export class ReplacePanelFlyout extends React.Component { }, }); + (container as DashboardContainer).setHighlightPanelId(id); this.showToast(name); this.props.onClose(); }; diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/controls_toolbar_button/add_data_control_button.tsx b/src/plugins/dashboard/public/dashboard_app/top_nav/controls_toolbar_button/add_data_control_button.tsx index 6cef7e858b1655..e7c7daa2bcc274 100644 --- a/src/plugins/dashboard/public/dashboard_app/top_nav/controls_toolbar_button/add_data_control_button.tsx +++ b/src/plugins/dashboard/public/dashboard_app/top_nav/controls_toolbar_button/add_data_control_button.tsx @@ -10,6 +10,7 @@ import React from 'react'; import { EuiContextMenuItem } from '@elastic/eui'; import { ControlGroupContainer } from '@kbn/controls-plugin/public'; import { getAddControlButtonTitle } from '../../_dashboard_app_strings'; +import { useDashboardAPI } from '../../dashboard_app'; interface Props { closePopover: () => void; @@ -17,6 +18,11 @@ interface Props { } export const AddDataControlButton = ({ closePopover, controlGroup, ...rest }: Props) => { + const dashboard = useDashboardAPI(); + const onSave = () => { + dashboard.scrollToTop(); + }; + return ( { - controlGroup.openAddDataControlFlyout(); + controlGroup.openAddDataControlFlyout({ onSave }); closePopover(); }} > diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/controls_toolbar_button/add_time_slider_control_button.tsx b/src/plugins/dashboard/public/dashboard_app/top_nav/controls_toolbar_button/add_time_slider_control_button.tsx index 8283144e1c1553..cbd514be8ba135 100644 --- a/src/plugins/dashboard/public/dashboard_app/top_nav/controls_toolbar_button/add_time_slider_control_button.tsx +++ b/src/plugins/dashboard/public/dashboard_app/top_nav/controls_toolbar_button/add_time_slider_control_button.tsx @@ -13,6 +13,7 @@ import { getAddTimeSliderControlButtonTitle, getOnlyOneTimeSliderControlMsg, } from '../../_dashboard_app_strings'; +import { useDashboardAPI } from '../../dashboard_app'; interface Props { closePopover: () => void; @@ -21,6 +22,7 @@ interface Props { export const AddTimeSliderControlButton = ({ closePopover, controlGroup, ...rest }: Props) => { const [hasTimeSliderControl, setHasTimeSliderControl] = useState(false); + const dashboard = useDashboardAPI(); useEffect(() => { const subscription = controlGroup.getInput$().subscribe(() => { @@ -42,8 +44,9 @@ export const AddTimeSliderControlButton = ({ closePopover, controlGroup, ...rest { - controlGroup.addTimeSliderControl(); + onClick={async () => { + await controlGroup.addTimeSliderControl(); + dashboard.scrollToTop(); closePopover(); }} data-test-subj="controls-create-timeslider-button" diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_editing_toolbar.tsx b/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_editing_toolbar.tsx index 03b609ae99736d..708af176d785d4 100644 --- a/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_editing_toolbar.tsx +++ b/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_editing_toolbar.tsx @@ -110,6 +110,8 @@ export function DashboardEditingToolbar() { const newEmbeddable = await dashboard.addNewEmbeddable(embeddableFactory.type, explicitInput); if (newEmbeddable) { + dashboard.setScrollToPanelId(newEmbeddable.id); + dashboard.setHighlightPanelId(newEmbeddable.id); toasts.addSuccess({ title: dashboardReplacePanelActionStrings.getSuccessMessage(newEmbeddable.getTitle()), 'data-test-subj': 'addEmbeddableToDashboardSuccess', diff --git a/src/plugins/dashboard/public/dashboard_container/component/grid/_dashboard_grid.scss b/src/plugins/dashboard/public/dashboard_container/component/grid/_dashboard_grid.scss index 7e9529a90be8b5..cc96c816ce8b78 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/grid/_dashboard_grid.scss +++ b/src/plugins/dashboard/public/dashboard_container/component/grid/_dashboard_grid.scss @@ -36,10 +36,13 @@ } /** - * When a single panel is expanded, all the other panels are hidden in the grid. + * When a single panel is expanded, all the other panels moved offscreen. + * Shifting the rendered panels offscreen prevents a quick flash when redrawing the panels on minimize */ .dshDashboardGrid__item--hidden { - display: none; + position: absolute; + top: -9999px; + left: -9999px; } /** @@ -53,11 +56,12 @@ * 1. We need to mark this as important because react grid layout sets the width and height of the panels inline. */ .dshDashboardGrid__item--expanded { + position: absolute; height: 100% !important; /* 1 */ width: 100% !important; /* 1 */ top: 0 !important; /* 1 */ left: 0 !important; /* 1 */ - transform: translate(0, 0) !important; /* 1 */ + transform: none !important; padding: $euiSizeS; // Altered panel styles can be found in ../panel diff --git a/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.tsx b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.tsx index b840fcd408977d..0055e24685b89d 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.tsx +++ b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.tsx @@ -12,7 +12,7 @@ import 'react-grid-layout/css/styles.css'; import { pick } from 'lodash'; import classNames from 'classnames'; import { useEffectOnce } from 'react-use/lib'; -import React, { useState, useMemo, useCallback } from 'react'; +import React, { useState, useMemo, useCallback, useEffect } from 'react'; import { Layout, Responsive as ResponsiveReactGridLayout } from 'react-grid-layout'; import { ViewMode } from '@kbn/embeddable-plugin/public'; @@ -38,6 +38,15 @@ export const DashboardGrid = ({ viewportWidth }: { viewportWidth: number }) => { setTimeout(() => setAnimatePanelTransforms(true), 500); }); + useEffect(() => { + if (expandedPanelId) { + setAnimatePanelTransforms(false); + } else { + // delaying enabling CSS transforms to the next tick prevents a panel slide animation on minimize + setTimeout(() => setAnimatePanelTransforms(true), 0); + } + }, [expandedPanelId]); + const { onPanelStatusChange } = useDashboardPerformanceTracker({ panelCount: Object.keys(panels).length, }); @@ -98,7 +107,7 @@ export const DashboardGrid = ({ viewportWidth }: { viewportWidth: number }) => { 'dshLayout-withoutMargins': !useMargins, 'dshLayout--viewing': viewMode === ViewMode.VIEW, 'dshLayout--editing': viewMode !== ViewMode.VIEW, - 'dshLayout--noAnimation': !animatePanelTransforms, + 'dshLayout--noAnimation': !animatePanelTransforms || expandedPanelId, 'dshLayout-isMaximizedPanel': expandedPanelId !== undefined, }); diff --git a/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.tsx b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.tsx index 45aa70fd50febc..39ff6ebc484184 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.tsx +++ b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState, useRef, useEffect, useLayoutEffect } from 'react'; import { EuiLoadingChart } from '@elastic/eui'; import classNames from 'classnames'; @@ -56,6 +56,8 @@ const Item = React.forwardRef( embeddable: { EmbeddablePanel: PanelComponent }, } = pluginServices.getServices(); const container = useDashboardContainer(); + const scrollToPanelId = container.select((state) => state.componentState.scrollToPanelId); + const highlightPanelId = container.select((state) => state.componentState.highlightPanelId); const expandPanel = expandedPanelId !== undefined && expandedPanelId === id; const hidePanel = expandedPanelId !== undefined && expandedPanelId !== id; @@ -66,11 +68,23 @@ const Item = React.forwardRef( printViewport__vis: container.getInput().viewMode === ViewMode.PRINT, }); + useLayoutEffect(() => { + if (typeof ref !== 'function' && ref?.current) { + if (scrollToPanelId === id) { + container.scrollToPanel(ref.current); + } + if (highlightPanelId === id) { + container.highlightPanel(ref.current); + } + } + }, [id, container, scrollToPanelId, highlightPanelId, ref]); + return (
diff --git a/src/plugins/dashboard/public/dashboard_container/component/panel/_dashboard_panel.scss b/src/plugins/dashboard/public/dashboard_container/component/panel/_dashboard_panel.scss index f04e5e29d960b7..f8715220ddf378 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/panel/_dashboard_panel.scss +++ b/src/plugins/dashboard/public/dashboard_container/component/panel/_dashboard_panel.scss @@ -11,6 +11,10 @@ box-shadow: none; border-radius: 0; } + + .dshDashboardGrid__item--highlighted { + border-radius: 0; + } } // Remove border color unless in editing mode @@ -25,3 +29,24 @@ cursor: default; } } + +@keyframes highlightOutline { + 0% { + outline: solid $euiSizeXS transparentize($euiColorSuccess, 1); + } + 25% { + outline: solid $euiSizeXS transparentize($euiColorSuccess, .5); + } + 100% { + outline: solid $euiSizeXS transparentize($euiColorSuccess, 1); + } +} + +.dshDashboardGrid__item--highlighted { + border-radius: $euiSizeXS; + animation-name: highlightOutline; + animation-duration: 4s; + animation-timing-function: ease-out; + // keeps outline from getting cut off by other panels without margins + z-index: 999 !important; +} diff --git a/src/plugins/dashboard/public/dashboard_container/component/panel/dashboard_panel_placement.ts b/src/plugins/dashboard/public/dashboard_container/component/panel/dashboard_panel_placement.ts index 77b51874319baa..e570e1eadd6ca7 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/panel/dashboard_panel_placement.ts +++ b/src/plugins/dashboard/public/dashboard_container/component/panel/dashboard_panel_placement.ts @@ -24,6 +24,7 @@ export interface IPanelPlacementArgs { width: number; height: number; currentPanels: { [key: string]: DashboardPanelState }; + scrollToPanel?: boolean; } export interface IPanelPlacementBesideArgs extends IPanelPlacementArgs { diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/api/add_panel_from_library.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/api/add_panel_from_library.ts index ef4f4dc7ea5c9b..c708937e3d56e4 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/api/add_panel_from_library.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/api/add_panel_from_library.ts @@ -41,6 +41,10 @@ export function addFromLibrary(this: DashboardContainer) { notifications, overlays, theme, + onAddPanel: (id: string) => { + this.setScrollToPanelId(id); + this.setHighlightPanelId(id); + }, }) ); } diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/api/panel_management.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/api/panel_management.ts index cb2ce9af37bcd8..7b02001a93c6c4 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/api/panel_management.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/api/panel_management.ts @@ -128,7 +128,12 @@ export function showPlaceholderUntil newStateComplete) - .then((newPanelState: Partial) => - this.replacePanel(placeholderPanelState, newPanelState) - ); + .then(async (newPanelState: Partial) => { + const panelId = await this.replacePanel(placeholderPanelState, newPanelState); + + if (placementArgs?.scrollToPanel) { + this.setScrollToPanelId(panelId); + this.setHighlightPanelId(panelId); + } + }); } diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts index 7609a4f3eb95fe..f0a20e832e431f 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts @@ -181,12 +181,13 @@ export const createDashboard = async ( const incomingEmbeddable = creationOptions?.incomingEmbeddable; if (incomingEmbeddable) { initialInput.viewMode = ViewMode.EDIT; // view mode must always be edit to recieve an embeddable. - if ( + + const panelExists = incomingEmbeddable.embeddableId && - Boolean(initialInput.panels[incomingEmbeddable.embeddableId]) - ) { + Boolean(initialInput.panels[incomingEmbeddable.embeddableId]); + if (panelExists) { // this embeddable already exists, we will update the explicit input. - const panelToUpdate = initialInput.panels[incomingEmbeddable.embeddableId]; + const panelToUpdate = initialInput.panels[incomingEmbeddable.embeddableId as string]; const sameType = panelToUpdate.type === incomingEmbeddable.type; panelToUpdate.type = incomingEmbeddable.type; @@ -195,17 +196,22 @@ export const createDashboard = async ( ...(sameType ? panelToUpdate.explicitInput : {}), ...incomingEmbeddable.input, - id: incomingEmbeddable.embeddableId, + id: incomingEmbeddable.embeddableId as string, // maintain hide panel titles setting. hidePanelTitles: panelToUpdate.explicitInput.hidePanelTitles, }; } else { // otherwise this incoming embeddable is brand new and can be added via the default method after the dashboard container is created. - untilDashboardReady().then((container) => - container.addNewEmbeddable(incomingEmbeddable.type, incomingEmbeddable.input) - ); + untilDashboardReady().then(async (container) => { + container.addNewEmbeddable(incomingEmbeddable.type, incomingEmbeddable.input); + }); } + + untilDashboardReady().then(async (container) => { + container.setScrollToPanelId(incomingEmbeddable.embeddableId); + container.setHighlightPanelId(incomingEmbeddable.embeddableId); + }); } // -------------------------------------------------------------------------------------- diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx index 5b7a589afa950b..d5a5385e779b36 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx @@ -398,4 +398,41 @@ export class DashboardContainer extends Container { + this.dispatch.setScrollToPanelId(id); + }; + + public scrollToPanel = async (panelRef: HTMLDivElement) => { + const id = this.getState().componentState.scrollToPanelId; + if (!id) return; + + this.untilEmbeddableLoaded(id).then(() => { + this.setScrollToPanelId(undefined); + panelRef.scrollIntoView({ block: 'center' }); + }); + }; + + public scrollToTop = () => { + window.scroll(0, 0); + }; + + public setHighlightPanelId = (id: string | undefined) => { + this.dispatch.setHighlightPanelId(id); + }; + + public highlightPanel = (panelRef: HTMLDivElement) => { + const id = this.getState().componentState.highlightPanelId; + + if (id && panelRef) { + this.untilEmbeddableLoaded(id).then(() => { + panelRef.classList.add('dshDashboardGrid__item--highlighted'); + // Removes the class after the highlight animation finishes + setTimeout(() => { + panelRef.classList.remove('dshDashboardGrid__item--highlighted'); + }, 5000); + }); + } + this.setHighlightPanelId(undefined); + }; } diff --git a/src/plugins/dashboard/public/dashboard_container/state/dashboard_container_reducers.ts b/src/plugins/dashboard/public/dashboard_container/state/dashboard_container_reducers.ts index 70bf3a7d659896..86a58bb72f639b 100644 --- a/src/plugins/dashboard/public/dashboard_container/state/dashboard_container_reducers.ts +++ b/src/plugins/dashboard/public/dashboard_container/state/dashboard_container_reducers.ts @@ -209,4 +209,12 @@ export const dashboardContainerReducers = { setHasOverlays: (state: DashboardReduxState, action: PayloadAction) => { state.componentState.hasOverlays = action.payload; }, + + setScrollToPanelId: (state: DashboardReduxState, action: PayloadAction) => { + state.componentState.scrollToPanelId = action.payload; + }, + + setHighlightPanelId: (state: DashboardReduxState, action: PayloadAction) => { + state.componentState.highlightPanelId = action.payload; + }, }; diff --git a/src/plugins/dashboard/public/dashboard_container/types.ts b/src/plugins/dashboard/public/dashboard_container/types.ts index 6e8ff1f5c98a05..544317d9f6bccf 100644 --- a/src/plugins/dashboard/public/dashboard_container/types.ts +++ b/src/plugins/dashboard/public/dashboard_container/types.ts @@ -33,6 +33,8 @@ export interface DashboardPublicState { fullScreenMode?: boolean; savedQueryId?: string; lastSavedId?: string; + scrollToPanelId?: string; + highlightPanelId?: string; } export interface DashboardRenderPerformanceStats { diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.tsx index dcaa3880678abb..ea7c150bf38b8a 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.tsx @@ -30,6 +30,7 @@ interface Props { SavedObjectFinder: React.ComponentType; showCreateNewMenu?: boolean; reportUiCounter?: UsageCollectionStart['reportUiCounter']; + onAddPanel?: (id: string) => void; } interface State { @@ -101,7 +102,7 @@ export class AddPanelFlyout extends React.Component { throw new EmbeddableFactoryNotFoundError(savedObjectType); } - this.props.container.addNewEmbeddable( + const embeddable = await this.props.container.addNewEmbeddable( factoryForSavedObjectType.type, { savedObjectId } ); @@ -109,6 +110,9 @@ export class AddPanelFlyout extends React.Component { this.doTelemetryForAddEvent(this.props.container.type, factoryForSavedObjectType, so); this.showToast(name); + if (this.props.onAddPanel) { + this.props.onAddPanel(embeddable.id); + } }; private doTelemetryForAddEvent( diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/open_add_panel_flyout.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/open_add_panel_flyout.tsx index 4cc5a7ccb6e11f..eb2722dcf98690 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/open_add_panel_flyout.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/open_add_panel_flyout.tsx @@ -24,6 +24,7 @@ export function openAddPanelFlyout(options: { showCreateNewMenu?: boolean; reportUiCounter?: UsageCollectionStart['reportUiCounter']; theme: ThemeServiceStart; + onAddPanel?: (id: string) => void; }): OverlayRef { const { embeddable, @@ -35,11 +36,13 @@ export function openAddPanelFlyout(options: { showCreateNewMenu, reportUiCounter, theme, + onAddPanel, } = options; const flyoutSession = overlays.openFlyout( toMountPoint( { if (flyoutSession) { flyoutSession.close(); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/disabled_callout.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/disabled_callout.tsx index 0a75b9a44499bd..885f44eaa5ae1d 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/disabled_callout.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/disabled_callout.tsx @@ -6,41 +6,24 @@ */ import React from 'react'; -import { EuiButton, EuiCallOut, EuiLink } from '@elastic/eui'; -import { InvalidApiKeyCalloutCallout } from './invalid_api_key_callout'; +import { EuiCallOut, EuiLink, EuiSpacer } from '@elastic/eui'; import * as labels from './labels'; import { useEnablement } from '../../../hooks'; export const DisabledCallout = ({ total }: { total: number }) => { - const { enablement, enableSynthetics, invalidApiKeyError, loading } = useEnablement(); + const { enablement, invalidApiKeyError, loading } = useEnablement(); const showDisableCallout = !enablement.isEnabled && total > 0; - const showInvalidApiKeyError = invalidApiKeyError && total > 0; + const showInvalidApiKeyCallout = invalidApiKeyError && total > 0; - if (showInvalidApiKeyError) { - return ; - } - - if (!showDisableCallout) { + if (!showDisableCallout && !showInvalidApiKeyCallout) { return null; } - return ( - -

{labels.CALLOUT_MANAGEMENT_DESCRIPTION}

- {enablement.canEnable || loading ? ( - { - enableSynthetics(); - }} - isLoading={loading} - > - {labels.SYNTHETICS_ENABLE_LABEL} - - ) : ( + return !enablement.canEnable && !loading ? ( + <> + +

{labels.CALLOUT_MANAGEMENT_DESCRIPTION}

{labels.CALLOUT_MANAGEMENT_CONTACT_ADMIN}{' '} { {labels.LEARN_MORE_LABEL}

- )} -
- ); +
+ + + ) : null; }; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/invalid_api_key_callout.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/invalid_api_key_callout.tsx deleted file mode 100644 index 70816a69c2188b..00000000000000 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/invalid_api_key_callout.tsx +++ /dev/null @@ -1,80 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { EuiButton, EuiCallOut, EuiLink, EuiSpacer } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { useEnablement } from '../../../hooks'; - -export const InvalidApiKeyCalloutCallout = () => { - const { enablement, enableSynthetics, loading } = useEnablement(); - - return ( - <> - -

{CALLOUT_MANAGEMENT_DESCRIPTION}

- {enablement.canEnable || loading ? ( - { - enableSynthetics(); - }} - isLoading={loading} - > - {SYNTHETICS_ENABLE_LABEL} - - ) : ( -

- {CALLOUT_MANAGEMENT_CONTACT_ADMIN}{' '} - - {LEARN_MORE_LABEL} - -

- )} -
- - - ); -}; - -const LEARN_MORE_LABEL = i18n.translate( - 'xpack.synthetics.monitorManagement.manageMonitorLoadingLabel.callout.invalidKey', - { - defaultMessage: 'Learn more', - } -); - -const API_KEY_MISSING = i18n.translate('xpack.synthetics.monitorManagement.callout.apiKeyMissing', { - defaultMessage: 'Monitor Management is currently disabled because of missing API key', -}); - -const CALLOUT_MANAGEMENT_CONTACT_ADMIN = i18n.translate( - 'xpack.synthetics.monitorManagement.callout.disabledCallout.invalidKey', - { - defaultMessage: 'Contact your administrator to enable Monitor Management.', - } -); - -const CALLOUT_MANAGEMENT_DESCRIPTION = i18n.translate( - 'xpack.synthetics.monitorManagement.callout.description.invalidKey', - { - defaultMessage: `Monitor Management is currently disabled. To run your monitors in one of Elastic's global managed testing locations, you need to re-enable monitor management.`, - } -); - -const SYNTHETICS_ENABLE_LABEL = i18n.translate( - 'xpack.synthetics.monitorManagement.syntheticsEnableLabel.invalidKey', - { - defaultMessage: 'Enable monitor management', - } -); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/labels.ts b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/labels.ts index aca280e74fcb23..ff297267dcb62d 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/labels.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/labels.ts @@ -24,14 +24,14 @@ export const LEARN_MORE_LABEL = i18n.translate( export const CALLOUT_MANAGEMENT_DISABLED = i18n.translate( 'xpack.synthetics.monitorManagement.callout.disabled', { - defaultMessage: 'Monitor Management is disabled', + defaultMessage: 'Monitor Management is currently disabled', } ); export const CALLOUT_MANAGEMENT_CONTACT_ADMIN = i18n.translate( 'xpack.synthetics.monitorManagement.callout.disabled.adminContact', { - defaultMessage: 'Please contact your administrator to enable Monitor Management.', + defaultMessage: 'Monitor Management will be enabled when an admin visits the Synthetics app.', } ); @@ -39,7 +39,7 @@ export const CALLOUT_MANAGEMENT_DESCRIPTION = i18n.translate( 'xpack.synthetics.monitorManagement.callout.description.disabled', { defaultMessage: - 'Monitor Management is currently disabled. To run your monitors on Elastic managed Synthetics service, enable Monitor Management. Your existing monitors are paused.', + "Monitor Management requires a valid API key to run your monitors on Elastic's global managed testing locations. If you already had enabled Monitor Management previously, the API key may no longer be valid.", } ); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/synthetics_enablement/synthetics_enablement.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/synthetics_enablement/synthetics_enablement.tsx index d6b927bbc43b3c..e4fcee12f65a51 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/synthetics_enablement/synthetics_enablement.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/synthetics_enablement/synthetics_enablement.tsx @@ -6,16 +6,16 @@ */ import React, { useState, useEffect, useRef } from 'react'; -import { EuiEmptyPrompt, EuiButton, EuiTitle, EuiLink } from '@elastic/eui'; +import { EuiEmptyPrompt, EuiTitle, EuiLink } from '@elastic/eui'; import { useEnablement } from '../../../../hooks/use_enablement'; import { kibanaService } from '../../../../../../utils/kibana_service'; import * as labels from './labels'; export const EnablementEmptyState = () => { - const { error, enablement, enableSynthetics, loading } = useEnablement(); + const { error, enablement, loading } = useEnablement(); const [shouldFocusEnablementButton, setShouldFocusEnablementButton] = useState(false); const [isEnabling, setIsEnabling] = useState(false); - const { isEnabled, canEnable } = enablement; + const { isEnabled } = enablement; const isEnabledRef = useRef(isEnabled); const buttonRef = useRef(null); @@ -44,11 +44,6 @@ export const EnablementEmptyState = () => { } }, [isEnabled, isEnabling, error]); - const handleEnableSynthetics = () => { - enableSynthetics(); - setIsEnabling(true); - }; - useEffect(() => { if (shouldFocusEnablementButton) { buttonRef.current?.focus(); @@ -57,33 +52,8 @@ export const EnablementEmptyState = () => { return !isEnabled && !loading ? ( - {canEnable - ? labels.MONITOR_MANAGEMENT_ENABLEMENT_LABEL - : labels.SYNTHETICS_APP_DISABLED_LABEL} - - } - body={ -

- {canEnable - ? labels.MONITOR_MANAGEMENT_ENABLEMENT_MESSAGE - : labels.MONITOR_MANAGEMENT_DISABLED_MESSAGE} -

- } - actions={ - canEnable ? ( - - {labels.MONITOR_MANAGEMENT_ENABLEMENT_BTN_LABEL} - - ) : null - } + title={

{labels.SYNTHETICS_APP_DISABLED_LABEL}

} + body={

{labels.MONITOR_MANAGEMENT_DISABLED_MESSAGE}

} footer={ <> diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview_page.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview_page.tsx index a98f78249adbe3..2e4eb6ca03a31c 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview_page.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview_page.tsx @@ -101,8 +101,8 @@ export const OverviewPage: React.FC = () => { return ( <> - + diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_enablement.ts b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_enablement.ts index 394da8aefc0868..fe726d0cbe3d2e 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_enablement.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_enablement.ts @@ -5,14 +5,9 @@ * 2.0. */ -import { useEffect, useCallback } from 'react'; +import { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { - getSyntheticsEnablement, - enableSynthetics, - disableSynthetics, - selectSyntheticsEnablement, -} from '../state'; +import { getSyntheticsEnablement, selectSyntheticsEnablement } from '../state'; export function useEnablement() { const dispatch = useDispatch(); @@ -35,7 +30,5 @@ export function useEnablement() { invalidApiKeyError: enablement ? !Boolean(enablement?.isValidApiKey) : false, error, loading, - enableSynthetics: useCallback(() => dispatch(enableSynthetics()), [dispatch]), - disableSynthetics: useCallback(() => dispatch(disableSynthetics()), [dispatch]), }; } diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/synthetics_enablement/actions.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/synthetics_enablement/actions.ts index 7369ce0917e5a9..78c0d9484149ed 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/state/synthetics_enablement/actions.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/synthetics_enablement/actions.ts @@ -16,17 +16,3 @@ export const getSyntheticsEnablementSuccess = createAction( '[SYNTHETICS_ENABLEMENT] GET FAILURE' ); - -export const disableSynthetics = createAction('[SYNTHETICS_ENABLEMENT] DISABLE'); -export const disableSyntheticsSuccess = createAction<{}>('[SYNTHETICS_ENABLEMENT] DISABLE SUCCESS'); -export const disableSyntheticsFailure = createAction( - '[SYNTHETICS_ENABLEMENT] DISABLE FAILURE' -); - -export const enableSynthetics = createAction('[SYNTHETICS_ENABLEMENT] ENABLE'); -export const enableSyntheticsSuccess = createAction( - '[SYNTHETICS_ENABLEMENT] ENABLE SUCCESS' -); -export const enableSyntheticsFailure = createAction( - '[SYNTHETICS_ENABLEMENT] ENABLE FAILURE' -); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/synthetics_enablement/api.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/synthetics_enablement/api.ts index 62b48676e39653..2e009cc0b89d21 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/state/synthetics_enablement/api.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/synthetics_enablement/api.ts @@ -14,17 +14,9 @@ import { apiService } from '../../../../utils/api_service'; export const fetchGetSyntheticsEnablement = async (): Promise => { - return await apiService.get( + return await apiService.put( API_URLS.SYNTHETICS_ENABLEMENT, undefined, MonitorManagementEnablementResultCodec ); }; - -export const fetchDisableSynthetics = async (): Promise<{}> => { - return await apiService.delete(API_URLS.SYNTHETICS_ENABLEMENT); -}; - -export const fetchEnableSynthetics = async (): Promise => { - return await apiService.post(API_URLS.SYNTHETICS_ENABLEMENT); -}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/synthetics_enablement/effects.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/synthetics_enablement/effects.ts index d3134c60f8fd37..14c912b07ce99a 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/state/synthetics_enablement/effects.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/synthetics_enablement/effects.ts @@ -5,20 +5,15 @@ * 2.0. */ -import { takeLatest, takeLeading } from 'redux-saga/effects'; +import { takeLeading } from 'redux-saga/effects'; +import { i18n } from '@kbn/i18n'; import { getSyntheticsEnablement, getSyntheticsEnablementSuccess, getSyntheticsEnablementFailure, - disableSynthetics, - disableSyntheticsSuccess, - disableSyntheticsFailure, - enableSynthetics, - enableSyntheticsSuccess, - enableSyntheticsFailure, } from './actions'; -import { fetchGetSyntheticsEnablement, fetchDisableSynthetics, fetchEnableSynthetics } from './api'; import { fetchEffectFactory } from '../utils/fetch_effect'; +import { fetchGetSyntheticsEnablement } from './api'; export function* fetchSyntheticsEnablementEffect() { yield takeLeading( @@ -26,15 +21,13 @@ export function* fetchSyntheticsEnablementEffect() { fetchEffectFactory( fetchGetSyntheticsEnablement, getSyntheticsEnablementSuccess, - getSyntheticsEnablementFailure + getSyntheticsEnablementFailure, + undefined, + failureMessage ) ); - yield takeLatest( - disableSynthetics, - fetchEffectFactory(fetchDisableSynthetics, disableSyntheticsSuccess, disableSyntheticsFailure) - ); - yield takeLatest( - enableSynthetics, - fetchEffectFactory(fetchEnableSynthetics, enableSyntheticsSuccess, enableSyntheticsFailure) - ); } + +const failureMessage = i18n.translate('xpack.synthetics.settings.enablement.fail', { + defaultMessage: 'Failed to enable Monitor Management', +}); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/synthetics_enablement/index.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/synthetics_enablement/index.ts index 62cbce9bfe05b5..26bf2b50b8325b 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/state/synthetics_enablement/index.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/synthetics_enablement/index.ts @@ -9,12 +9,6 @@ import { createReducer } from '@reduxjs/toolkit'; import { getSyntheticsEnablement, getSyntheticsEnablementSuccess, - disableSynthetics, - disableSyntheticsSuccess, - disableSyntheticsFailure, - enableSynthetics, - enableSyntheticsSuccess, - enableSyntheticsFailure, getSyntheticsEnablementFailure, } from './actions'; import { MonitorManagementEnablementResult } from '../../../../../common/runtime_types'; @@ -45,39 +39,6 @@ export const syntheticsEnablementReducer = createReducer(initialState, (builder) .addCase(getSyntheticsEnablementFailure, (state, action) => { state.loading = false; state.error = action.payload; - }) - - .addCase(disableSynthetics, (state) => { - state.loading = true; - }) - .addCase(disableSyntheticsSuccess, (state, action) => { - state.loading = false; - state.error = null; - state.enablement = { - canEnable: state.enablement?.canEnable ?? false, - areApiKeysEnabled: state.enablement?.areApiKeysEnabled ?? false, - canManageApiKeys: state.enablement?.canManageApiKeys ?? false, - isEnabled: false, - isValidApiKey: true, - }; - }) - .addCase(disableSyntheticsFailure, (state, action) => { - state.loading = false; - state.error = action.payload; - }) - - .addCase(enableSynthetics, (state) => { - state.loading = true; - state.enablement = null; - }) - .addCase(enableSyntheticsSuccess, (state, action) => { - state.loading = false; - state.error = null; - state.enablement = action.payload; - }) - .addCase(enableSyntheticsFailure, (state, action) => { - state.loading = false; - state.error = action.payload; }); }); diff --git a/x-pack/plugins/synthetics/server/legacy_uptime/lib/saved_objects/service_api_key.ts b/x-pack/plugins/synthetics/server/legacy_uptime/lib/saved_objects/service_api_key.ts index adab53c9d42683..3c62f99f7e67be 100644 --- a/x-pack/plugins/synthetics/server/legacy_uptime/lib/saved_objects/service_api_key.ts +++ b/x-pack/plugins/synthetics/server/legacy_uptime/lib/saved_objects/service_api_key.ts @@ -72,7 +72,7 @@ const getSyntheticsServiceAPIKey = async (server: UptimeServerSetup) => { } }; -const setSyntheticsServiceApiKey = async ( +export const setSyntheticsServiceApiKey = async ( soClient: SavedObjectsClientContract, apiKey: SyntheticsServiceApiKey ) => { diff --git a/x-pack/plugins/synthetics/server/routes/index.ts b/x-pack/plugins/synthetics/server/routes/index.ts index 9e2038d05962a0..836143d55f014e 100644 --- a/x-pack/plugins/synthetics/server/routes/index.ts +++ b/x-pack/plugins/synthetics/server/routes/index.ts @@ -20,7 +20,6 @@ import { getServiceLocationsRoute } from './synthetics_service/get_service_locat import { deleteSyntheticsMonitorRoute } from './monitor_cruds/delete_monitor'; import { disableSyntheticsRoute, - enableSyntheticsRoute, getSyntheticsEnablementRoute, } from './synthetics_service/enablement'; import { @@ -61,7 +60,6 @@ export const syntheticsAppRestApiRoutes: SyntheticsRestApiRouteFactory[] = [ deleteSyntheticsMonitorProjectRoute, disableSyntheticsRoute, editSyntheticsMonitorRoute, - enableSyntheticsRoute, getServiceLocationsRoute, getSyntheticsMonitorRoute, getSyntheticsProjectMonitorsRoute, diff --git a/x-pack/plugins/synthetics/server/routes/synthetics_service/enablement.ts b/x-pack/plugins/synthetics/server/routes/synthetics_service/enablement.ts index c4561f3ee9e00b..87a10dbee9a8eb 100644 --- a/x-pack/plugins/synthetics/server/routes/synthetics_service/enablement.ts +++ b/x-pack/plugins/synthetics/server/routes/synthetics_service/enablement.ts @@ -5,18 +5,12 @@ * 2.0. */ import { syntheticsServiceAPIKeySavedObject } from '../../legacy_uptime/lib/saved_objects/service_api_key'; -import { - SyntheticsRestApiRouteFactory, - UMRestApiRouteFactory, -} from '../../legacy_uptime/routes/types'; +import { SyntheticsRestApiRouteFactory } from '../../legacy_uptime/routes/types'; import { API_URLS } from '../../../common/constants'; -import { - generateAndSaveServiceAPIKey, - SyntheticsForbiddenError, -} from '../../synthetics_service/get_api_key'; +import { generateAndSaveServiceAPIKey } from '../../synthetics_service/get_api_key'; -export const getSyntheticsEnablementRoute: UMRestApiRouteFactory = (libs) => ({ - method: 'GET', +export const getSyntheticsEnablementRoute: SyntheticsRestApiRouteFactory = (libs) => ({ + method: 'PUT', path: API_URLS.SYNTHETICS_ENABLEMENT, validate: {}, handler: async ({ savedObjectsClient, request, server }): Promise => { @@ -25,7 +19,18 @@ export const getSyntheticsEnablementRoute: UMRestApiRouteFactory = (libs) => ({ server, }); const { canEnable, isEnabled } = result; - if (canEnable && !isEnabled && server.config.service?.manifestUrl) { + const { security } = server; + const { apiKey, isValid } = await libs.requests.getAPIKeyForSyntheticsService({ + server, + }); + if (apiKey && !isValid) { + await syntheticsServiceAPIKeySavedObject.delete(savedObjectsClient); + await security.authc.apiKeys?.invalidateAsInternalUser({ + ids: [apiKey?.id || ''], + }); + } + const regenerationRequired = !isEnabled || !isValid; + if (canEnable && regenerationRequired && server.config.service?.manifestUrl) { await generateAndSaveServiceAPIKey({ request, authSavedObjectsClient: savedObjectsClient, @@ -68,7 +73,7 @@ export const disableSyntheticsRoute: SyntheticsRestApiRouteFactory = (libs) => ( server, }); await syntheticsServiceAPIKeySavedObject.delete(savedObjectsClient); - await security.authc.apiKeys?.invalidate(request, { ids: [apiKey?.id || ''] }); + await security.authc.apiKeys?.invalidateAsInternalUser({ ids: [apiKey?.id || ''] }); return response.ok({}); } catch (e) { server.logger.error(e); @@ -76,30 +81,3 @@ export const disableSyntheticsRoute: SyntheticsRestApiRouteFactory = (libs) => ( } }, }); - -export const enableSyntheticsRoute: UMRestApiRouteFactory = (libs) => ({ - method: 'POST', - path: API_URLS.SYNTHETICS_ENABLEMENT, - validate: {}, - handler: async ({ request, response, server, savedObjectsClient }): Promise => { - const { logger } = server; - try { - await generateAndSaveServiceAPIKey({ - request, - authSavedObjectsClient: savedObjectsClient, - server, - }); - return response.ok({ - body: await libs.requests.getSyntheticsEnablement({ - server, - }), - }); - } catch (e) { - logger.error(e); - if (e instanceof SyntheticsForbiddenError) { - return response.forbidden(); - } - throw e; - } - }, -}); diff --git a/x-pack/plugins/synthetics/server/synthetics_service/get_api_key.test.ts b/x-pack/plugins/synthetics/server/synthetics_service/get_api_key.test.ts index 4b15f4da43515d..ca4a18e88d5d95 100644 --- a/x-pack/plugins/synthetics/server/synthetics_service/get_api_key.test.ts +++ b/x-pack/plugins/synthetics/server/synthetics_service/get_api_key.test.ts @@ -25,16 +25,6 @@ describe('getAPIKeyTest', function () { const logger = loggerMock.create(); - jest.spyOn(authUtils, 'checkHasPrivileges').mockResolvedValue({ - index: { - [syntheticsIndex]: { - auto_configure: true, - create_doc: true, - view_index_metadata: true, - }, - }, - } as any); - const server = { logger, security, @@ -52,6 +42,20 @@ describe('getAPIKeyTest', function () { encoded: '@#$%^&', }); + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(authUtils, 'checkHasPrivileges').mockResolvedValue({ + index: { + [syntheticsIndex]: { + auto_configure: true, + create_doc: true, + view_index_metadata: true, + read: true, + }, + }, + } as any); + }); + it('should return existing api key', async () => { const getObject = jest .fn() @@ -79,4 +83,43 @@ describe('getAPIKeyTest', function () { 'ba997842-b0cf-4429-aa9d-578d9bf0d391' ); }); + + it('invalidates api keys with missing read permissions', async () => { + jest.spyOn(authUtils, 'checkHasPrivileges').mockResolvedValue({ + index: { + [syntheticsIndex]: { + auto_configure: true, + create_doc: true, + view_index_metadata: true, + read: false, + }, + }, + } as any); + + const getObject = jest + .fn() + .mockReturnValue({ attributes: { apiKey: 'qwerty', id: 'test', name: 'service-api-key' } }); + + encryptedSavedObjects.getClient = jest.fn().mockReturnValue({ + getDecryptedAsInternalUser: getObject, + }); + const apiKey = await getAPIKeyForSyntheticsService({ + server, + }); + + expect(apiKey).toEqual({ + apiKey: { apiKey: 'qwerty', id: 'test', name: 'service-api-key' }, + isValid: false, + }); + + expect(encryptedSavedObjects.getClient).toHaveBeenCalledTimes(1); + expect(getObject).toHaveBeenCalledTimes(1); + expect(encryptedSavedObjects.getClient).toHaveBeenCalledWith({ + includedHiddenTypes: [syntheticsServiceApiKey.name], + }); + expect(getObject).toHaveBeenCalledWith( + 'uptime-synthetics-api-key', + 'ba997842-b0cf-4429-aa9d-578d9bf0d391' + ); + }); }); diff --git a/x-pack/plugins/synthetics/server/synthetics_service/get_api_key.ts b/x-pack/plugins/synthetics/server/synthetics_service/get_api_key.ts index 0bc4f656901f45..79af4d6cfc7187 100644 --- a/x-pack/plugins/synthetics/server/synthetics_service/get_api_key.ts +++ b/x-pack/plugins/synthetics/server/synthetics_service/get_api_key.ts @@ -56,7 +56,8 @@ export const getAPIKeyForSyntheticsService = async ({ const hasPermissions = indexPermissions.auto_configure && indexPermissions.create_doc && - indexPermissions.view_index_metadata; + indexPermissions.view_index_metadata && + indexPermissions.read; if (!hasPermissions) { return { isValid: false, apiKey }; @@ -92,6 +93,7 @@ export const generateAPIKey = async ({ } if (uptimePrivileges) { + /* Exposed to the user. Must create directly with the user */ return security.authc.apiKeys?.create(request, { name: 'synthetics-api-key (required for project monitors)', kibana_role_descriptors: { @@ -122,7 +124,8 @@ export const generateAPIKey = async ({ throw new SyntheticsForbiddenError(); } - return security.authc.apiKeys?.create(request, { + /* Not exposed to the user. May grant as internal user */ + return security.authc.apiKeys?.grantAsInternalUser(request, { name: 'synthetics-api-key (required for monitor management)', role_descriptors: { synthetics_writer: serviceApiKeyPrivileges, @@ -160,23 +163,24 @@ export const generateAndSaveServiceAPIKey = async ({ export const getSyntheticsEnablement = async ({ server }: { server: UptimeServerSetup }) => { const { security, config } = server; + const [apiKey, hasPrivileges, areApiKeysEnabled] = await Promise.all([ + getAPIKeyForSyntheticsService({ server }), + hasEnablePermissions(server), + security.authc.apiKeys.areAPIKeysEnabled(), + ]); + + const { canEnable, canManageApiKeys } = hasPrivileges; + if (!config.service?.manifestUrl) { return { canEnable: true, - canManageApiKeys: true, + canManageApiKeys, isEnabled: true, isValidApiKey: true, areApiKeysEnabled: true, }; } - const [apiKey, hasPrivileges, areApiKeysEnabled] = await Promise.all([ - getAPIKeyForSyntheticsService({ server }), - hasEnablePermissions(server), - security.authc.apiKeys.areAPIKeysEnabled(), - ]); - - const { canEnable, canManageApiKeys } = hasPrivileges; return { canEnable, canManageApiKeys, @@ -217,7 +221,7 @@ const hasEnablePermissions = async ({ uptimeEsClient }: UptimeServerSetup) => { return { canManageApiKeys, - canEnable: canManageApiKeys && hasClusterPermissions && hasIndexPermissions, + canEnable: hasClusterPermissions && hasIndexPermissions, }; }; diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index f7cf6a952a7961..fda996fca0a651 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -34922,12 +34922,9 @@ "xpack.synthetics.monitorManagement.apiKey.label": "Clé d'API", "xpack.synthetics.monitorManagement.apiKeyWarning.label": "Cette clé d’API ne sera affichée qu'une seule fois. Veuillez en conserver une copie pour vos propres dossiers.", "xpack.synthetics.monitorManagement.areYouSure": "Voulez-vous vraiment supprimer cet emplacement ?", - "xpack.synthetics.monitorManagement.callout.apiKeyMissing": "La Gestion des moniteurs est actuellement désactivée en raison d'une clé d'API manquante", "xpack.synthetics.monitorManagement.callout.description.disabled": "La Gestion des moniteurs est actuellement désactivée. Pour exécuter vos moniteurs sur le service Synthetics géré par Elastic, activez la Gestion des moniteurs. Vos moniteurs existants ont été suspendus.", - "xpack.synthetics.monitorManagement.callout.description.invalidKey": "La Gestion des moniteurs est actuellement désactivée. Pour exécuter vos moniteurs dans l'un des emplacements de tests gérés globaux d'Elastic, vous devez ré-activer la Gestion des moniteurs.", "xpack.synthetics.monitorManagement.callout.disabled": "La Gestion des moniteurs est désactivée", "xpack.synthetics.monitorManagement.callout.disabled.adminContact": "Veuillez contacter votre administrateur pour activer la Gestion des moniteurs.", - "xpack.synthetics.monitorManagement.callout.disabledCallout.invalidKey": "Contactez votre administrateur pour activer la Gestion des moniteurs.", "xpack.synthetics.monitorManagement.cancelLabel": "Annuler", "xpack.synthetics.monitorManagement.cannotSaveIntegration": "Vous n'êtes pas autorisé à mettre à jour les intégrations. Des autorisations d'écriture pour les intégrations sont requises.", "xpack.synthetics.monitorManagement.closeButtonLabel": "Fermer", @@ -34976,7 +34973,6 @@ "xpack.synthetics.monitorManagement.locationName": "Nom de l’emplacement", "xpack.synthetics.monitorManagement.locationsLabel": "Emplacements", "xpack.synthetics.monitorManagement.manageMonitorLoadingLabel": "Chargement de la liste Gestion des moniteurs", - "xpack.synthetics.monitorManagement.manageMonitorLoadingLabel.callout.invalidKey": "En savoir plus", "xpack.synthetics.monitorManagement.manageMonitorLoadingLabel.callout.learnMore": "En savoir plus.", "xpack.synthetics.monitorManagement.monitorAddedSuccessMessage": "Moniteur ajouté avec succès.", "xpack.synthetics.monitorManagement.monitorEditedSuccessMessage": "Moniteur mis à jour.", @@ -35017,7 +35013,6 @@ "xpack.synthetics.monitorManagement.steps": "Étapes", "xpack.synthetics.monitorManagement.summary.heading": "Résumé", "xpack.synthetics.monitorManagement.syntheticsDisabledSuccess": "Gestion des moniteurs désactivée avec succès.", - "xpack.synthetics.monitorManagement.syntheticsEnableLabel.invalidKey": "Activer la Gestion des moniteurs", "xpack.synthetics.monitorManagement.syntheticsEnableLabel.management": "Activer la Gestion des moniteurs", "xpack.synthetics.monitorManagement.syntheticsEnableSuccess": "Gestion des moniteurs activée avec succès.", "xpack.synthetics.monitorManagement.testResult": "Résultat du test", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 2e5fdf22d200bb..11ccd44a6eee76 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -34901,12 +34901,9 @@ "xpack.synthetics.monitorManagement.apiKey.label": "API キー", "xpack.synthetics.monitorManagement.apiKeyWarning.label": "このAPIキーは1回だけ表示されます。自分の記録用にコピーして保管してください。", "xpack.synthetics.monitorManagement.areYouSure": "この場所を削除しますか?", - "xpack.synthetics.monitorManagement.callout.apiKeyMissing": "現在、APIキーがないため、モニター管理は無効です", "xpack.synthetics.monitorManagement.callout.description.disabled": "モニター管理は現在無効です。Elasticで管理されたSyntheticsサービスでモニターを実行するには、モニター管理を有効にします。既存のモニターが一時停止しています。", - "xpack.synthetics.monitorManagement.callout.description.invalidKey": "モニター管理は現在無効です。Elasticのグローバル管理されたテストロケーションのいずれかでモニターを実行するには、モニター管理を再有効化する必要があります。", "xpack.synthetics.monitorManagement.callout.disabled": "モニター管理が無効です", "xpack.synthetics.monitorManagement.callout.disabled.adminContact": "モニター管理を有効にするには、管理者に連絡してください。", - "xpack.synthetics.monitorManagement.callout.disabledCallout.invalidKey": "モニター管理を有効にするには、管理者に連絡してください。", "xpack.synthetics.monitorManagement.cancelLabel": "キャンセル", "xpack.synthetics.monitorManagement.cannotSaveIntegration": "統合を更新する権限がありません。統合書き込み権限が必要です。", "xpack.synthetics.monitorManagement.closeButtonLabel": "閉じる", @@ -34955,7 +34952,6 @@ "xpack.synthetics.monitorManagement.locationName": "場所名", "xpack.synthetics.monitorManagement.locationsLabel": "場所", "xpack.synthetics.monitorManagement.manageMonitorLoadingLabel": "モニター管理を読み込んでいます", - "xpack.synthetics.monitorManagement.manageMonitorLoadingLabel.callout.invalidKey": "詳細", "xpack.synthetics.monitorManagement.manageMonitorLoadingLabel.callout.learnMore": "詳細情報", "xpack.synthetics.monitorManagement.monitorAddedSuccessMessage": "モニターが正常に追加されました。", "xpack.synthetics.monitorManagement.monitorEditedSuccessMessage": "モニターは正常に更新されました。", @@ -34996,7 +34992,6 @@ "xpack.synthetics.monitorManagement.steps": "ステップ", "xpack.synthetics.monitorManagement.summary.heading": "まとめ", "xpack.synthetics.monitorManagement.syntheticsDisabledSuccess": "モニター管理は正常に無効にされました。", - "xpack.synthetics.monitorManagement.syntheticsEnableLabel.invalidKey": "モニター管理を有効にする", "xpack.synthetics.monitorManagement.syntheticsEnableLabel.management": "モニター管理を有効にする", "xpack.synthetics.monitorManagement.syntheticsEnableSuccess": "モニター管理は正常に有効にされました。", "xpack.synthetics.monitorManagement.testResult": "テスト結果", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 7199ea360f5e5b..4eae043b7b8846 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -34917,12 +34917,9 @@ "xpack.synthetics.monitorManagement.apiKey.label": "API 密钥", "xpack.synthetics.monitorManagement.apiKeyWarning.label": "此 API 密钥仅显示一次。请保留副本作为您自己的记录。", "xpack.synthetics.monitorManagement.areYouSure": "是否确定要删除此位置?", - "xpack.synthetics.monitorManagement.callout.apiKeyMissing": "由于缺少 API 密钥,监测管理当前已禁用", "xpack.synthetics.monitorManagement.callout.description.disabled": "监测管理当前处于禁用状态。要在 Elastic 托管 Synthetics 服务上运行监测,请启用监测管理。现有监测已暂停。", - "xpack.synthetics.monitorManagement.callout.description.invalidKey": "监测管理当前处于禁用状态。要在 Elastic 的全球托管测试位置之一运行监测,您需要重新启用监测管理。", "xpack.synthetics.monitorManagement.callout.disabled": "已禁用监测管理", "xpack.synthetics.monitorManagement.callout.disabled.adminContact": "请联系管理员启用监测管理。", - "xpack.synthetics.monitorManagement.callout.disabledCallout.invalidKey": "请联系管理员启用监测管理。", "xpack.synthetics.monitorManagement.cancelLabel": "取消", "xpack.synthetics.monitorManagement.cannotSaveIntegration": "您无权更新集成。需要集成写入权限。", "xpack.synthetics.monitorManagement.closeButtonLabel": "关闭", @@ -34971,7 +34968,6 @@ "xpack.synthetics.monitorManagement.locationName": "位置名称", "xpack.synthetics.monitorManagement.locationsLabel": "位置", "xpack.synthetics.monitorManagement.manageMonitorLoadingLabel": "正在加载监测管理", - "xpack.synthetics.monitorManagement.manageMonitorLoadingLabel.callout.invalidKey": "了解详情", "xpack.synthetics.monitorManagement.manageMonitorLoadingLabel.callout.learnMore": "了解详情。", "xpack.synthetics.monitorManagement.monitorAddedSuccessMessage": "已成功添加监测。", "xpack.synthetics.monitorManagement.monitorEditedSuccessMessage": "已成功更新监测。", @@ -35012,7 +35008,6 @@ "xpack.synthetics.monitorManagement.steps": "步长", "xpack.synthetics.monitorManagement.summary.heading": "摘要", "xpack.synthetics.monitorManagement.syntheticsDisabledSuccess": "已成功禁用监测管理。", - "xpack.synthetics.monitorManagement.syntheticsEnableLabel.invalidKey": "启用监测管理", "xpack.synthetics.monitorManagement.syntheticsEnableLabel.management": "启用监测管理", "xpack.synthetics.monitorManagement.syntheticsEnableSuccess": "已成功启用监测管理。", "xpack.synthetics.monitorManagement.testResult": "测试结果", diff --git a/x-pack/test/api_integration/apis/synthetics/add_monitor_project.ts b/x-pack/test/api_integration/apis/synthetics/add_monitor_project.ts index b1bc58abe71138..e6a8ae14cda1ae 100644 --- a/x-pack/test/api_integration/apis/synthetics/add_monitor_project.ts +++ b/x-pack/test/api_integration/apis/synthetics/add_monitor_project.ts @@ -74,7 +74,7 @@ export default function ({ getService }: FtrProviderContext) { }; before(async () => { - await supertest.post(API_URLS.SYNTHETICS_ENABLEMENT).set('kbn-xsrf', 'true').expect(200); + await supertest.put(API_URLS.SYNTHETICS_ENABLEMENT).set('kbn-xsrf', 'true').expect(200); await supertest.post('/api/fleet/setup').set('kbn-xsrf', 'true').send().expect(200); await supertest .post('/api/fleet/epm/packages/synthetics/0.11.4') diff --git a/x-pack/test/api_integration/apis/synthetics/edit_monitor.ts b/x-pack/test/api_integration/apis/synthetics/edit_monitor.ts index f20a2cdf61a45c..bf4447a1b59699 100644 --- a/x-pack/test/api_integration/apis/synthetics/edit_monitor.ts +++ b/x-pack/test/api_integration/apis/synthetics/edit_monitor.ts @@ -43,7 +43,7 @@ export default function ({ getService }: FtrProviderContext) { before(async () => { _httpMonitorJson = getFixtureJson('http_monitor'); await supertest.post('/api/fleet/setup').set('kbn-xsrf', 'true').send().expect(200); - await supertest.post(API_URLS.SYNTHETICS_ENABLEMENT).set('kbn-xsrf', 'true').expect(200); + await supertest.put(API_URLS.SYNTHETICS_ENABLEMENT).set('kbn-xsrf', 'true').expect(200); const testPolicyName = 'Fleet test server policy' + Date.now(); const apiResponse = await testPrivateLocations.addFleetPolicy(testPolicyName); diff --git a/x-pack/test/api_integration/apis/synthetics/get_monitor.ts b/x-pack/test/api_integration/apis/synthetics/get_monitor.ts index 5394ca64545e69..00772c5550ac17 100644 --- a/x-pack/test/api_integration/apis/synthetics/get_monitor.ts +++ b/x-pack/test/api_integration/apis/synthetics/get_monitor.ts @@ -32,7 +32,7 @@ export default function ({ getService }: FtrProviderContext) { }; before(async () => { - await supertest.post(API_URLS.SYNTHETICS_ENABLEMENT).set('kbn-xsrf', 'true').expect(200); + await supertest.put(API_URLS.SYNTHETICS_ENABLEMENT).set('kbn-xsrf', 'true').expect(200); _monitors = [ getFixtureJson('icmp_monitor'), diff --git a/x-pack/test/api_integration/apis/synthetics/get_monitor_overview.ts b/x-pack/test/api_integration/apis/synthetics/get_monitor_overview.ts index 25aad0704cddd0..625dbdac61608f 100644 --- a/x-pack/test/api_integration/apis/synthetics/get_monitor_overview.ts +++ b/x-pack/test/api_integration/apis/synthetics/get_monitor_overview.ts @@ -55,7 +55,7 @@ export default function ({ getService }: FtrProviderContext) { }; before(async () => { - await supertest.post(API_URLS.SYNTHETICS_ENABLEMENT).set('kbn-xsrf', 'true').expect(200); + await supertest.put(API_URLS.SYNTHETICS_ENABLEMENT).set('kbn-xsrf', 'true').expect(200); await kibanaServer.spaces.create({ id: SPACE_ID, name: SPACE_NAME }); await security.role.create(roleName, { kibana: [ diff --git a/x-pack/test/api_integration/apis/synthetics/synthetics_enablement.ts b/x-pack/test/api_integration/apis/synthetics/synthetics_enablement.ts index d031a6c505c8fa..bf68da4c148f5b 100644 --- a/x-pack/test/api_integration/apis/synthetics/synthetics_enablement.ts +++ b/x-pack/test/api_integration/apis/synthetics/synthetics_enablement.ts @@ -6,24 +6,57 @@ */ import { API_URLS } from '@kbn/synthetics-plugin/common/constants'; +import { + syntheticsApiKeyID, + syntheticsApiKeyObjectType, +} from '@kbn/synthetics-plugin/server/legacy_uptime/lib/saved_objects/service_api_key'; import { serviceApiKeyPrivileges } from '@kbn/synthetics-plugin/server/synthetics_service/get_api_key'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { + const correctPrivileges = { + applications: [], + cluster: ['monitor', 'read_ilm', 'read_pipeline'], + indices: [ + { + allow_restricted_indices: false, + names: ['synthetics-*'], + privileges: ['view_index_metadata', 'create_doc', 'auto_configure', 'read'], + }, + ], + metadata: {}, + run_as: [], + transient_metadata: { + enabled: true, + }, + }; + describe('SyntheticsEnablement', () => { const supertestWithAuth = getService('supertest'); const supertest = getService('supertestWithoutAuth'); const security = getService('security'); const kibanaServer = getService('kibanaServer'); - before(async () => { - await supertestWithAuth.delete(API_URLS.SYNTHETICS_ENABLEMENT).set('kbn-xsrf', 'true'); - }); - - describe('[GET] - /internal/uptime/service/enablement', () => { - ['manage_security', 'manage_own_api_key', 'manage_api_key'].forEach((privilege) => { - it(`returns response for an admin with privilege ${privilege}`, async () => { + const esSupertest = getService('esSupertest'); + + const getApiKeys = async () => { + const { body } = await esSupertest.get(`/_security/api_key`).query({ with_limited_by: true }); + const apiKeys = body.api_keys || []; + return apiKeys.filter( + (apiKey: any) => apiKey.name.includes('synthetics-api-key') && apiKey.invalidated === false + ); + }; + + describe('[PUT] /internal/uptime/service/enablement', () => { + beforeEach(async () => { + const apiKeys = await getApiKeys(); + if (apiKeys.length) { + await supertestWithAuth.delete(API_URLS.SYNTHETICS_ENABLEMENT).set('kbn-xsrf', 'true'); + } + }); + ['manage_security', 'manage_api_key', 'manage_own_api_key'].forEach((privilege) => { + it(`returns response when user can manage api keys`, async () => { const username = 'admin'; const roleName = `synthetics_admin-${privilege}`; const password = `${username}-password`; @@ -38,7 +71,7 @@ export default function ({ getService }: FtrProviderContext) { }, ], elasticsearch: { - cluster: [privilege, ...serviceApiKeyPrivileges.cluster], + cluster: [privilege], indices: serviceApiKeyPrivileges.indices, }, }); @@ -50,7 +83,7 @@ export default function ({ getService }: FtrProviderContext) { }); const apiResponse = await supertest - .get(API_URLS.SYNTHETICS_ENABLEMENT) + .put(API_URLS.SYNTHETICS_ENABLEMENT) .auth(username, password) .set('kbn-xsrf', 'true') .expect(200); @@ -58,17 +91,10 @@ export default function ({ getService }: FtrProviderContext) { expect(apiResponse.body).eql({ areApiKeysEnabled: true, canManageApiKeys: true, - canEnable: true, - isEnabled: true, - isValidApiKey: true, + canEnable: false, + isEnabled: false, + isValidApiKey: false, }); - if (privilege !== 'manage_own_api_key') { - await supertest - .delete(API_URLS.SYNTHETICS_ENABLEMENT) - .auth(username, password) - .set('kbn-xsrf', 'true') - .expect(200); - } } finally { await security.user.delete(username); await security.role.delete(roleName); @@ -76,9 +102,9 @@ export default function ({ getService }: FtrProviderContext) { }); }); - it('returns response for an uptime all user without admin privileges', async () => { - const username = 'uptime'; - const roleName = 'uptime_user'; + it(`returns response for an admin with privilege`, async () => { + const username = 'admin'; + const roleName = `synthetics_admin`; const password = `${username}-password`; try { await security.role.create(roleName, { @@ -90,7 +116,10 @@ export default function ({ getService }: FtrProviderContext) { spaces: ['*'], }, ], - elasticsearch: {}, + elasticsearch: { + cluster: serviceApiKeyPrivileges.cluster, + indices: serviceApiKeyPrivileges.indices, + }, }); await security.user.create(username, { @@ -100,7 +129,7 @@ export default function ({ getService }: FtrProviderContext) { }); const apiResponse = await supertest - .get(API_URLS.SYNTHETICS_ENABLEMENT) + .put(API_URLS.SYNTHETICS_ENABLEMENT) .auth(username, password) .set('kbn-xsrf', 'true') .expect(200); @@ -108,19 +137,20 @@ export default function ({ getService }: FtrProviderContext) { expect(apiResponse.body).eql({ areApiKeysEnabled: true, canManageApiKeys: false, - canEnable: false, - isEnabled: false, - isValidApiKey: false, + canEnable: true, + isEnabled: true, + isValidApiKey: true, }); + const validApiKeys = await getApiKeys(); + expect(validApiKeys.length).eql(1); + expect(validApiKeys[0].role_descriptors.synthetics_writer).eql(correctPrivileges); } finally { - await security.role.delete(roleName); await security.user.delete(username); + await security.role.delete(roleName); } }); - }); - describe('[POST] - /internal/uptime/service/enablement', () => { - it('with an admin', async () => { + it(`does not create excess api keys`, async () => { const username = 'admin'; const roleName = `synthetics_admin`; const password = `${username}-password`; @@ -135,7 +165,7 @@ export default function ({ getService }: FtrProviderContext) { }, ], elasticsearch: { - cluster: ['manage_security', ...serviceApiKeyPrivileges.cluster], + cluster: serviceApiKeyPrivileges.cluster, indices: serviceApiKeyPrivileges.indices, }, }); @@ -146,38 +176,213 @@ export default function ({ getService }: FtrProviderContext) { full_name: 'a kibana user', }); - await supertest - .post(API_URLS.SYNTHETICS_ENABLEMENT) + const apiResponse = await supertest + .put(API_URLS.SYNTHETICS_ENABLEMENT) + .auth(username, password) + .set('kbn-xsrf', 'true') + .expect(200); + + expect(apiResponse.body).eql({ + areApiKeysEnabled: true, + canManageApiKeys: false, + canEnable: true, + isEnabled: true, + isValidApiKey: true, + }); + + const validApiKeys = await getApiKeys(); + expect(validApiKeys.length).eql(1); + expect(validApiKeys[0].role_descriptors.synthetics_writer).eql(correctPrivileges); + + // call api a second time + const apiResponse2 = await supertest + .put(API_URLS.SYNTHETICS_ENABLEMENT) .auth(username, password) .set('kbn-xsrf', 'true') .expect(200); + + expect(apiResponse2.body).eql({ + areApiKeysEnabled: true, + canManageApiKeys: false, + canEnable: true, + isEnabled: true, + isValidApiKey: true, + }); + + const validApiKeys2 = await getApiKeys(); + expect(validApiKeys2.length).eql(1); + expect(validApiKeys2[0].role_descriptors.synthetics_writer).eql(correctPrivileges); + } finally { + await security.user.delete(username); + await security.role.delete(roleName); + } + }); + + it(`auto re-enables the api key when created with invalid permissions and invalidates old api key`, async () => { + const username = 'admin'; + const roleName = `synthetics_admin`; + const password = `${username}-password`; + try { + // create api key with incorrect permissions + const apiKeyResult = await esSupertest + .post(`/_security/api_key`) + .send({ + name: 'synthetics-api-key', + expiration: '1d', + role_descriptors: { + 'role-a': { + cluster: serviceApiKeyPrivileges.cluster, + indices: [ + { + names: ['synthetics-*'], + privileges: ['view_index_metadata', 'create_doc', 'auto_configure'], + }, + ], + }, + }, + }) + .expect(200); + kibanaServer.savedObjects.create({ + id: syntheticsApiKeyID, + type: syntheticsApiKeyObjectType, + overwrite: true, + attributes: { + id: apiKeyResult.body.id, + name: 'synthetics-api-key (required for monitor management)', + apiKey: apiKeyResult.body.api_key, + }, + }); + + const validApiKeys = await getApiKeys(); + expect(validApiKeys.length).eql(1); + expect(validApiKeys[0].role_descriptors.synthetics_writer).not.eql(correctPrivileges); + + await security.role.create(roleName, { + kibana: [ + { + feature: { + uptime: ['all'], + }, + spaces: ['*'], + }, + ], + elasticsearch: { + cluster: serviceApiKeyPrivileges.cluster, + indices: serviceApiKeyPrivileges.indices, + }, + }); + + await security.user.create(username, { + password, + roles: [roleName], + full_name: 'a kibana user', + }); + const apiResponse = await supertest - .get(API_URLS.SYNTHETICS_ENABLEMENT) + .put(API_URLS.SYNTHETICS_ENABLEMENT) .auth(username, password) .set('kbn-xsrf', 'true') .expect(200); expect(apiResponse.body).eql({ areApiKeysEnabled: true, - canManageApiKeys: true, + canManageApiKeys: false, canEnable: true, isEnabled: true, isValidApiKey: true, }); + + const validApiKeys2 = await getApiKeys(); + expect(validApiKeys2.length).eql(1); + expect(validApiKeys2[0].role_descriptors.synthetics_writer).eql(correctPrivileges); } finally { - await supertest - .delete(API_URLS.SYNTHETICS_ENABLEMENT) + await security.user.delete(username); + await security.role.delete(roleName); + } + }); + + it(`auto re-enables api key when invalidated`, async () => { + const username = 'admin'; + const roleName = `synthetics_admin`; + const password = `${username}-password`; + try { + await security.role.create(roleName, { + kibana: [ + { + feature: { + uptime: ['all'], + }, + spaces: ['*'], + }, + ], + elasticsearch: { + cluster: serviceApiKeyPrivileges.cluster, + indices: serviceApiKeyPrivileges.indices, + }, + }); + + await security.user.create(username, { + password, + roles: [roleName], + full_name: 'a kibana user', + }); + + const apiResponse = await supertest + .put(API_URLS.SYNTHETICS_ENABLEMENT) + .auth(username, password) + .set('kbn-xsrf', 'true') + .expect(200); + + expect(apiResponse.body).eql({ + areApiKeysEnabled: true, + canManageApiKeys: false, + canEnable: true, + isEnabled: true, + isValidApiKey: true, + }); + + const validApiKeys = await getApiKeys(); + expect(validApiKeys.length).eql(1); + expect(validApiKeys[0].role_descriptors.synthetics_writer).eql(correctPrivileges); + + // delete api key + await esSupertest + .delete(`/_security/api_key`) + .send({ + ids: [validApiKeys[0].id], + }) + .expect(200); + + const validApiKeysAferDeletion = await getApiKeys(); + expect(validApiKeysAferDeletion.length).eql(0); + + // call api a second time + const apiResponse2 = await supertest + .put(API_URLS.SYNTHETICS_ENABLEMENT) .auth(username, password) .set('kbn-xsrf', 'true') .expect(200); + + expect(apiResponse2.body).eql({ + areApiKeysEnabled: true, + canManageApiKeys: false, + canEnable: true, + isEnabled: true, + isValidApiKey: true, + }); + + const validApiKeys2 = await getApiKeys(); + expect(validApiKeys2.length).eql(1); + expect(validApiKeys2[0].role_descriptors.synthetics_writer).eql(correctPrivileges); + } finally { await security.user.delete(username); await security.role.delete(roleName); } }); - it('with an uptime user', async () => { + it('returns response for an uptime all user without admin privileges', async () => { const username = 'uptime'; - const roleName = `uptime_user`; + const roleName = 'uptime_user'; const password = `${username}-password`; try { await security.role.create(roleName, { @@ -198,16 +403,12 @@ export default function ({ getService }: FtrProviderContext) { full_name: 'a kibana user', }); - await supertest - .post(API_URLS.SYNTHETICS_ENABLEMENT) - .auth(username, password) - .set('kbn-xsrf', 'true') - .expect(403); const apiResponse = await supertest - .get(API_URLS.SYNTHETICS_ENABLEMENT) + .put(API_URLS.SYNTHETICS_ENABLEMENT) .auth(username, password) .set('kbn-xsrf', 'true') .expect(200); + expect(apiResponse.body).eql({ areApiKeysEnabled: true, canManageApiKeys: false, @@ -216,13 +417,19 @@ export default function ({ getService }: FtrProviderContext) { isValidApiKey: false, }); } finally { - await security.user.delete(username); await security.role.delete(roleName); + await security.user.delete(username); } }); }); - describe('[DELETE] - /internal/uptime/service/enablement', () => { + describe('[DELETE] /internal/uptime/service/enablement', () => { + beforeEach(async () => { + const apiKeys = await getApiKeys(); + if (apiKeys.length) { + await supertestWithAuth.delete(API_URLS.SYNTHETICS_ENABLEMENT).set('kbn-xsrf', 'true'); + } + }); it('with an admin', async () => { const username = 'admin'; const roleName = `synthetics_admin`; @@ -238,7 +445,7 @@ export default function ({ getService }: FtrProviderContext) { }, ], elasticsearch: { - cluster: ['manage_security', ...serviceApiKeyPrivileges.cluster], + cluster: serviceApiKeyPrivileges.cluster, indices: serviceApiKeyPrivileges.indices, }, }); @@ -250,7 +457,7 @@ export default function ({ getService }: FtrProviderContext) { }); await supertest - .post(API_URLS.SYNTHETICS_ENABLEMENT) + .put(API_URLS.SYNTHETICS_ENABLEMENT) .auth(username, password) .set('kbn-xsrf', 'true') .expect(200); @@ -261,14 +468,14 @@ export default function ({ getService }: FtrProviderContext) { .expect(200); expect(delResponse.body).eql({}); const apiResponse = await supertest - .get(API_URLS.SYNTHETICS_ENABLEMENT) + .put(API_URLS.SYNTHETICS_ENABLEMENT) .auth(username, password) .set('kbn-xsrf', 'true') .expect(200); expect(apiResponse.body).eql({ areApiKeysEnabled: true, - canManageApiKeys: true, + canManageApiKeys: false, canEnable: true, isEnabled: true, isValidApiKey: true, @@ -303,7 +510,7 @@ export default function ({ getService }: FtrProviderContext) { }); await supertestWithAuth - .post(API_URLS.SYNTHETICS_ENABLEMENT) + .put(API_URLS.SYNTHETICS_ENABLEMENT) .set('kbn-xsrf', 'true') .expect(200); await supertest @@ -312,7 +519,7 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'true') .expect(403); const apiResponse = await supertest - .get(API_URLS.SYNTHETICS_ENABLEMENT) + .put(API_URLS.SYNTHETICS_ENABLEMENT) .auth(username, password) .set('kbn-xsrf', 'true') .expect(200); @@ -351,7 +558,7 @@ export default function ({ getService }: FtrProviderContext) { }, ], elasticsearch: { - cluster: ['manage_security', ...serviceApiKeyPrivileges.cluster], + cluster: serviceApiKeyPrivileges.cluster, indices: serviceApiKeyPrivileges.indices, }, }); @@ -364,21 +571,21 @@ export default function ({ getService }: FtrProviderContext) { // can enable synthetics in default space when enabled in a non default space const apiResponseGet = await supertest - .get(`/s/${SPACE_ID}${API_URLS.SYNTHETICS_ENABLEMENT}`) + .put(`/s/${SPACE_ID}${API_URLS.SYNTHETICS_ENABLEMENT}`) .auth(username, password) .set('kbn-xsrf', 'true') .expect(200); expect(apiResponseGet.body).eql({ areApiKeysEnabled: true, - canManageApiKeys: true, + canManageApiKeys: false, canEnable: true, isEnabled: true, isValidApiKey: true, }); await supertest - .post(`/s/${SPACE_ID}${API_URLS.SYNTHETICS_ENABLEMENT}`) + .put(`/s/${SPACE_ID}${API_URLS.SYNTHETICS_ENABLEMENT}`) .auth(username, password) .set('kbn-xsrf', 'true') .expect(200); @@ -388,14 +595,14 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'true') .expect(200); const apiResponse = await supertest - .get(API_URLS.SYNTHETICS_ENABLEMENT) + .put(API_URLS.SYNTHETICS_ENABLEMENT) .auth(username, password) .set('kbn-xsrf', 'true') .expect(200); expect(apiResponse.body).eql({ areApiKeysEnabled: true, - canManageApiKeys: true, + canManageApiKeys: false, canEnable: true, isEnabled: true, isValidApiKey: true, @@ -403,7 +610,7 @@ export default function ({ getService }: FtrProviderContext) { // can disable synthetics in non default space when enabled in default space await supertest - .post(API_URLS.SYNTHETICS_ENABLEMENT) + .put(API_URLS.SYNTHETICS_ENABLEMENT) .auth(username, password) .set('kbn-xsrf', 'true') .expect(200); @@ -413,14 +620,14 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'true') .expect(200); const apiResponse2 = await supertest - .get(API_URLS.SYNTHETICS_ENABLEMENT) + .put(API_URLS.SYNTHETICS_ENABLEMENT) .auth(username, password) .set('kbn-xsrf', 'true') .expect(200); expect(apiResponse2.body).eql({ areApiKeysEnabled: true, - canManageApiKeys: true, + canManageApiKeys: false, canEnable: true, isEnabled: true, isValidApiKey: true, @@ -428,6 +635,7 @@ export default function ({ getService }: FtrProviderContext) { } finally { await security.user.delete(username); await security.role.delete(roleName); + await kibanaServer.spaces.delete(SPACE_ID); } }); });