From 3c9da2cd296f9e23b5052e8bf624ddd062bcbcd0 Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau Date: Tue, 2 May 2023 09:47:29 -0400 Subject: [PATCH] [RAM] Apply maintenance windows privilege to UI (#156191) ## Summary We will have three scenarios with kibana privileges ### NONE Kibana privileges form maintenance window: image `The expected result is to not see maintenance window at all` image ### READ Kibana privileges form maintenance window: image `The expected result is to only see the table with window maintenance and you can not edit them` image image ### ALL Kibana privileges form maintenance window: image `The expected result is to be able to create/edit/etc on any maintenance windows` image image ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../alerting/public/lib/test_utils.tsx | 27 +++++- .../components/empty_prompt.tsx | 7 +- .../components/license_prompt.tsx | 1 + .../maintenance_windows_list.test.tsx | 26 +++++- .../components/maintenance_windows_list.tsx | 48 ++++++----- .../pages/maintenance_windows/index.test.tsx | 84 +++++++++++++++++++ .../pages/maintenance_windows/index.tsx | 50 +++++++++-- .../pages/maintenance_windows/translations.ts | 14 ++++ .../server/maintenance_window_feature.ts | 6 +- x-pack/plugins/alerting/tsconfig.json | 1 + .../maintenance_window_callout.test.tsx | 73 ++++++++++++++-- .../maintenance_window_callout.tsx | 20 ++++- .../use_fetch_active_maintenance_windows.ts | 4 +- .../feature_controls/management_security.ts | 8 +- 14 files changed, 327 insertions(+), 42 deletions(-) create mode 100644 x-pack/plugins/alerting/public/pages/maintenance_windows/index.test.tsx diff --git a/x-pack/plugins/alerting/public/lib/test_utils.tsx b/x-pack/plugins/alerting/public/lib/test_utils.tsx index 56485d7c88ad1..6e1642bfe0d36 100644 --- a/x-pack/plugins/alerting/public/lib/test_utils.tsx +++ b/x-pack/plugins/alerting/public/lib/test_utils.tsx @@ -11,7 +11,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { I18nProvider } from '@kbn/i18n-react'; import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; import { render as reactRender, RenderOptions, RenderResult } from '@testing-library/react'; -import { CoreStart } from '@kbn/core/public'; +import { Capabilities, CoreStart } from '@kbn/core/public'; import { coreMock } from '@kbn/core/public/mocks'; import { euiDarkVars } from '@kbn/ui-theme'; import type { ILicense } from '@kbn/licensing-plugin/public'; @@ -22,6 +22,7 @@ import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResult; interface AppMockRendererArgs { + capabilities?: Capabilities; license?: ILicense | null; } @@ -30,9 +31,15 @@ export interface AppMockRenderer { coreStart: CoreStart; queryClient: QueryClient; AppWrapper: React.FC<{ children: React.ReactElement }>; + mocked: { + setBadge: jest.Mock; + }; } -export const createAppMockRenderer = ({ license }: AppMockRendererArgs = {}): AppMockRenderer => { +export const createAppMockRenderer = ({ + capabilities, + license, +}: AppMockRendererArgs = {}): AppMockRenderer => { const theme$ = of({ eui: euiDarkVars, darkMode: true }); const licensingPluginMock = licensingMock.createStart(); @@ -53,13 +60,26 @@ export const createAppMockRenderer = ({ license }: AppMockRendererArgs = {}): Ap error: () => {}, }, }); + + const mockedSetBadge = jest.fn(); const core = coreMock.createStart(); const services = { ...core, + application: { + ...core.application, + capabilities: { + ...core.application.capabilities, + ...capabilities, + }, + }, licensing: license != null ? { ...licensingPluginMock, license$: new BehaviorSubject(license) } : licensingPluginMock, + chrome: { + ...core.chrome, + setBadge: mockedSetBadge, + }, }; const AppWrapper: React.FC<{ children: React.ReactElement }> = React.memo(({ children }) => ( @@ -85,5 +105,8 @@ export const createAppMockRenderer = ({ license }: AppMockRendererArgs = {}): Ap render, queryClient, AppWrapper, + mocked: { + setBadge: mockedSetBadge, + }, }; }; diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/empty_prompt.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/empty_prompt.tsx index 63e7a36c74e06..4b733f1a7289b 100644 --- a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/empty_prompt.tsx +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/empty_prompt.tsx @@ -41,7 +41,12 @@ export const EmptyPrompt = React.memo( }, [showCreateButton, onClickCreate, docLinks]); return ( - + ); } ); diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/license_prompt.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/license_prompt.tsx index 61de0593e387b..4d1b0588fda4a 100644 --- a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/license_prompt.tsx +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/license_prompt.tsx @@ -24,6 +24,7 @@ export const LicensePrompt = React.memo(() => { return ( diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/maintenance_windows_list.test.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/maintenance_windows_list.test.tsx index ed3fcb0839441..6cb9708199e0f 100644 --- a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/maintenance_windows_list.test.tsx +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/maintenance_windows_list.test.tsx @@ -94,7 +94,12 @@ describe('MaintenanceWindowsList', () => { test('it renders', () => { const result = appMockRenderer.render( - {}} loading={false} items={items} /> + {}} + loading={false} + items={items} + readOnly={false} + /> ); expect(result.getAllByTestId('list-item')).toHaveLength(items.length); @@ -115,5 +120,24 @@ describe('MaintenanceWindowsList', () => { // check the endDate formatting expect(result.getAllByText('05/05/23 00:00 AM')).toHaveLength(4); + + // check if action menu is there + expect(result.getAllByTestId('table-actions-icon-button')).toHaveLength(items.length); + }); + + test('it does NOT renders action column in readonly', () => { + const result = appMockRenderer.render( + {}} + loading={false} + items={items} + readOnly={true} + /> + ); + + expect(result.getAllByTestId('list-item')).toHaveLength(items.length); + + // check if action menu is there + expect(result.queryByTestId('table-actions-icon-button')).not.toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/maintenance_windows_list.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/maintenance_windows_list.tsx index 705219b9baa2a..7c817a1d70809 100644 --- a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/maintenance_windows_list.tsx +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/maintenance_windows_list.tsx @@ -32,10 +32,11 @@ import { useFinishAndArchiveMaintenanceWindow } from '../../../hooks/use_finish_ interface MaintenanceWindowsListProps { loading: boolean; items: MaintenanceWindowFindResponse[]; + readOnly: boolean; refreshData: () => void; } -const columns: Array> = [ +const COLUMNS: Array> = [ { field: 'title', name: i18n.NAME, @@ -99,7 +100,7 @@ const search: { filters: SearchFilterConfig[] } = { }; export const MaintenanceWindowsList = React.memo( - ({ loading, items, refreshData }) => { + ({ loading, items, readOnly, refreshData }) => { const { euiTheme } = useEuiTheme(); const { navigateToEditMaintenanceWindows } = useEditMaintenanceWindowsNavigation(); const onEdit = useCallback( @@ -139,32 +140,41 @@ export const MaintenanceWindowsList = React.memo( `; }, [euiTheme.colors.highlight]); - const actions: Array> = [ - { - name: '', - render: ({ status, id }: { status: MaintenanceWindowStatus; id: string }) => { - return ( - - ); + const actions: Array> = useMemo( + () => [ + { + name: '', + render: ({ status, id }: { status: MaintenanceWindowStatus; id: string }) => { + return ( + + ); + }, }, - }, - ]; + ], + [onArchive, onCancel, onCancelAndArchive, onEdit] + ); + + const columns = useMemo( + () => (readOnly ? COLUMNS : COLUMNS.concat(actions)), + [actions, readOnly] + ); return ( ({ + useFindMaintenanceWindows: jest.fn(), +})); + +describe('Maintenance windows page', () => { + let appMockRenderer: AppMockRenderer; + let license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + let capabilities: Capabilities = { + [MAINTENANCE_WINDOW_FEATURE_ID]: { + show: true, + save: true, + }, + navLinks: {}, + management: {}, + catalogue: {}, + }; + + beforeEach(() => { + jest.clearAllMocks(); + (useFindMaintenanceWindows as jest.Mock).mockReturnValue({ + isLoading: false, + maintenanceWindows: [], + refetch: jest.fn(), + }); + license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + capabilities = { + maintenanceWindow: { + show: true, + save: true, + }, + navLinks: {}, + management: {}, + catalogue: {}, + }; + appMockRenderer = createAppMockRenderer({ capabilities, license }); + }); + + test('show license prompt', () => { + license = licensingMock.createLicense({ + license: { type: 'gold' }, + }); + appMockRenderer = createAppMockRenderer({ capabilities, license }); + const result = appMockRenderer.render(); + expect(result.queryByTestId('mw-license-prompt')).toBeInTheDocument(); + }); + + test('show empty prompt', () => { + const result = appMockRenderer.render(); + expect(result.queryByTestId('mw-empty-prompt')).toBeInTheDocument(); + expect(appMockRenderer.mocked.setBadge).not.toBeCalled(); + }); + + test('show table in read only', () => { + capabilities = { + ...capabilities, + [MAINTENANCE_WINDOW_FEATURE_ID]: { + show: true, + save: false, + }, + }; + appMockRenderer = createAppMockRenderer({ capabilities, license }); + const result = appMockRenderer.render(); + expect(result.queryByTestId('mw-table')).toBeInTheDocument(); + expect(appMockRenderer.mocked.setBadge).toBeCalledTimes(1); + }); +}); diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/index.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/index.tsx index ac6d0b5534b9a..5eae620c5a3e5 100644 --- a/x-pack/plugins/alerting/public/pages/maintenance_windows/index.tsx +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { EuiButton, EuiFlexGroup, @@ -28,9 +28,14 @@ import { CenterJustifiedSpinner } from './components/center_justified_spinner'; import { ExperimentalBadge } from './components/page_header'; import { useLicense } from '../../hooks/use_license'; import { LicensePrompt } from './components/license_prompt'; +import { MAINTENANCE_WINDOW_FEATURE_ID } from '../../../common'; export const MaintenanceWindowsPage = React.memo(() => { - const { docLinks } = useKibana().services; + const { + application: { capabilities }, + chrome, + docLinks, + } = useKibana().services; const { isAtLeastPlatinum } = useLicense(); const { navigateToCreateMaintenanceWindow } = useCreateMaintenanceWindowNavigation(); @@ -44,10 +49,37 @@ export const MaintenanceWindowsPage = React.memo(() => { }, [navigateToCreateMaintenanceWindow]); const refreshData = useCallback(() => refetch(), [refetch]); - - const showEmptyPrompt = !isLoading && maintenanceWindows.length === 0; + const showWindowMaintenance = capabilities[MAINTENANCE_WINDOW_FEATURE_ID].show; + const writeWindowMaintenance = capabilities[MAINTENANCE_WINDOW_FEATURE_ID].save; + const showEmptyPrompt = + !isLoading && + maintenanceWindows.length === 0 && + showWindowMaintenance && + writeWindowMaintenance; const hasLicense = isAtLeastPlatinum(); + const readOnly = showWindowMaintenance && !writeWindowMaintenance; + + // if the user is read only then display the glasses badge in the global navigation header + const setBadge = useCallback(() => { + if (readOnly) { + chrome.setBadge({ + text: i18n.READ_ONLY_BADGE_TEXT, + tooltip: i18n.READ_ONLY_BADGE_TOOLTIP, + iconType: 'glasses', + }); + } + }, [chrome, readOnly]); + + useEffect(() => { + setBadge(); + + // remove the icon after the component unmounts + return () => { + chrome.setBadge(); + }; + }, [setBadge, chrome]); + if (isLoading) { return ; } @@ -71,9 +103,14 @@ export const MaintenanceWindowsPage = React.memo(() => {

{i18n.MAINTENANCE_WINDOWS_DESCRIPTION}

- {!showEmptyPrompt && hasLicense ? ( + {!showEmptyPrompt && hasLicense && writeWindowMaintenance ? ( - + {i18n.CREATE_NEW_BUTTON} @@ -87,6 +124,7 @@ export const MaintenanceWindowsPage = React.memo(() => { <> ({ fetchActiveMaintenanceWindows: jest.fn(() => Promise.resolve([])), })); +jest.mock('../../../../common/lib/kibana'); + const RUNNING_MAINTENANCE_WINDOW_1: Partial = { title: 'Maintenance window 1', id: '63057284-ac31-42ba-fe22-adfe9732e5ae', @@ -46,6 +52,9 @@ const UPCOMING_MAINTENANCE_WINDOW: Partial = { ], }; +const useKibanaMock = useKibana as jest.Mock; +const fetchActiveMaintenanceWindowsMock = fetchActiveMaintenanceWindows as jest.Mock; + describe('MaintenanceWindowCallout', () => { let appToastsMock: jest.Mocked>; @@ -54,6 +63,18 @@ describe('MaintenanceWindowCallout', () => { appToastsMock = useAppToastsMock.create(); (useAppToasts as jest.Mock).mockReturnValue(appToastsMock); + useKibanaMock.mockReturnValue({ + services: { + application: { + capabilities: { + [MAINTENANCE_WINDOW_FEATURE_ID]: { + save: true, + show: true, + }, + }, + }, + }, + }); }); afterEach(() => { @@ -62,7 +83,7 @@ describe('MaintenanceWindowCallout', () => { }); it('should be visible if currently there is at least one "running" maintenance window', async () => { - (fetchActiveMaintenanceWindows as jest.Mock).mockResolvedValue([RUNNING_MAINTENANCE_WINDOW_1]); + fetchActiveMaintenanceWindowsMock.mockResolvedValue([RUNNING_MAINTENANCE_WINDOW_1]); const { findByText } = render(, { wrapper: TestProviders }); @@ -70,7 +91,7 @@ describe('MaintenanceWindowCallout', () => { }); it('should be visible if currently there are multiple "running" maintenance windows', async () => { - (fetchActiveMaintenanceWindows as jest.Mock).mockResolvedValue([ + fetchActiveMaintenanceWindowsMock.mockResolvedValue([ RUNNING_MAINTENANCE_WINDOW_1, RUNNING_MAINTENANCE_WINDOW_2, ]); @@ -81,7 +102,7 @@ describe('MaintenanceWindowCallout', () => { }); it('should NOT be visible if currently there are no active (running or upcoming) maintenance windows', async () => { - (fetchActiveMaintenanceWindows as jest.Mock).mockResolvedValue([]); + fetchActiveMaintenanceWindowsMock.mockResolvedValue([]); const { container } = render(, { wrapper: TestProviders }); @@ -89,7 +110,7 @@ describe('MaintenanceWindowCallout', () => { }); it('should NOT be visible if currently there are no "running" maintenance windows', async () => { - (fetchActiveMaintenanceWindows as jest.Mock).mockResolvedValue([UPCOMING_MAINTENANCE_WINDOW]); + fetchActiveMaintenanceWindowsMock.mockResolvedValue([UPCOMING_MAINTENANCE_WINDOW]); const { container } = render(, { wrapper: TestProviders }); @@ -121,7 +142,7 @@ describe('MaintenanceWindowCallout', () => { }; const mockError = new Error('Network error'); - (fetchActiveMaintenanceWindows as jest.Mock).mockRejectedValue(mockError); + fetchActiveMaintenanceWindowsMock.mockRejectedValue(mockError); render(, { wrapper: createReactQueryWrapper() }); @@ -133,4 +154,44 @@ describe('MaintenanceWindowCallout', () => { }); }); }); + + it('should return null if window maintenance privilege is NONE', async () => { + useKibanaMock.mockReturnValue({ + services: { + application: { + capabilities: { + [MAINTENANCE_WINDOW_FEATURE_ID]: { + save: false, + show: false, + }, + }, + }, + }, + }); + fetchActiveMaintenanceWindowsMock.mockResolvedValue([RUNNING_MAINTENANCE_WINDOW_1]); + + const { container } = render(, { wrapper: TestProviders }); + + expect(container).toBeEmptyDOMElement(); + }); + + it('should work as expected if window maintenance privilege is READ ', async () => { + useKibanaMock.mockReturnValue({ + services: { + application: { + capabilities: { + [MAINTENANCE_WINDOW_FEATURE_ID]: { + save: false, + show: true, + }, + }, + }, + }, + }); + fetchActiveMaintenanceWindowsMock.mockResolvedValue([RUNNING_MAINTENANCE_WINDOW_1]); + + const { findByText } = render(, { wrapper: TestProviders }); + + expect(await findByText('A maintenance window is currently running')).toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/maintenance_window_callout/maintenance_window_callout.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/maintenance_window_callout/maintenance_window_callout.tsx index 878347dc37c98..5a1d21b39a65b 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/maintenance_window_callout/maintenance_window_callout.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/maintenance_window_callout/maintenance_window_callout.tsx @@ -7,12 +7,28 @@ import React from 'react'; import { EuiCallOut } from '@elastic/eui'; -import { MaintenanceWindowStatus } from '@kbn/alerting-plugin/common'; +import { + MaintenanceWindowStatus, + MAINTENANCE_WINDOW_FEATURE_ID, +} from '@kbn/alerting-plugin/common'; import { useFetchActiveMaintenanceWindows } from './use_fetch_active_maintenance_windows'; import * as i18n from './translations'; +import { useKibana } from '../../../../common/lib/kibana'; export function MaintenanceWindowCallout(): JSX.Element | null { - const { data } = useFetchActiveMaintenanceWindows(); + const { + application: { capabilities }, + } = useKibana().services; + + const isMaintenanceWindowDisabled = + !capabilities[MAINTENANCE_WINDOW_FEATURE_ID].show && + !capabilities[MAINTENANCE_WINDOW_FEATURE_ID].save; + const { data } = useFetchActiveMaintenanceWindows({ enabled: !isMaintenanceWindowDisabled }); + + if (isMaintenanceWindowDisabled) { + return null; + } + const activeMaintenanceWindows = data || []; if (activeMaintenanceWindows.some(({ status }) => status === MaintenanceWindowStatus.Running)) { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/maintenance_window_callout/use_fetch_active_maintenance_windows.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/maintenance_window_callout/use_fetch_active_maintenance_windows.ts index 3603cafbda935..69aecfa794357 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/maintenance_window_callout/use_fetch_active_maintenance_windows.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/maintenance_window_callout/use_fetch_active_maintenance_windows.ts @@ -5,19 +5,21 @@ * 2.0. */ +import type { UseQueryOptions } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query'; import { INTERNAL_ALERTING_API_GET_ACTIVE_MAINTENANCE_WINDOWS_PATH } from '@kbn/alerting-plugin/common'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import * as i18n from './translations'; import { fetchActiveMaintenanceWindows } from './api'; -export const useFetchActiveMaintenanceWindows = () => { +export const useFetchActiveMaintenanceWindows = ({ enabled }: Pick) => { const { addError } = useAppToasts(); return useQuery( ['GET', INTERNAL_ALERTING_API_GET_ACTIVE_MAINTENANCE_WINDOWS_PATH], ({ signal }) => fetchActiveMaintenanceWindows(signal), { + enabled, refetchInterval: 60000, onError: (error) => { addError(error, { title: i18n.FETCH_ERROR, toastMessage: i18n.FETCH_ERROR_DESCRIPTION }); diff --git a/x-pack/test/functional/apps/management/feature_controls/management_security.ts b/x-pack/test/functional/apps/management/feature_controls/management_security.ts index f6f71ad827d1c..d88e2fd8ebe7a 100644 --- a/x-pack/test/functional/apps/management/feature_controls/management_security.ts +++ b/x-pack/test/functional/apps/management/feature_controls/management_security.ts @@ -64,7 +64,13 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect(sections).to.have.length(2); expect(sections[0]).to.eql({ sectionId: 'insightsAndAlerting', - sectionLinks: ['triggersActions', 'cases', 'triggersActionsConnectors', 'jobsListLink'], + sectionLinks: [ + 'triggersActions', + 'cases', + 'triggersActionsConnectors', + 'jobsListLink', + 'maintenanceWindows', + ], }); expect(sections[1]).to.eql({ sectionId: 'kibana',