From b8396f48ce05f2c16d2fd9890f921260dd6a5a7d Mon Sep 17 00:00:00 2001 From: Juan Pablo Djeredjian Date: Wed, 13 Mar 2024 00:16:20 +0100 Subject: [PATCH] [Security Solution] Fix infinite loading state on Add Rules page for users with `Security: Read` permissions (#178005) Fixes: https://github.com/elastic/kibana/issues/161543 ## Summary Solves edge case of a `Security: Read` user visiting the Add Rules page before a user with permissions does (therefore the space has no permissions). This would cause the `/install/_review` call to never happen, and the page to get stuck in an infinite loading state. - Encapsulates logic to calculate if the `/install/_review` endpoint should be called - Allows `Security: Read` users to make the endpoint call `/install/_review` - The "All Elastic rules already installed" screen is shown to users in this edge case. - Adds frontend integration tests to Add Tables page ### Checklist - [ ] [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 - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed --- .../add_prebuilt_rules_table.test.tsx | 306 ++++++++++++++++++ .../add_prebuilt_rules_table_context.tsx | 11 +- .../add_prebuilt_rules_utils.ts | 34 ++ 3 files changed, 346 insertions(+), 5 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/add_prebuilt_rules_table.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/add_prebuilt_rules_utils.ts diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/add_prebuilt_rules_table.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/add_prebuilt_rules_table.test.tsx new file mode 100644 index 0000000000000..4be2547598b5d --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/add_prebuilt_rules_table.test.tsx @@ -0,0 +1,306 @@ +/* + * 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 { render, screen } from '@testing-library/react'; +import { AddPrebuiltRulesTable } from './add_prebuilt_rules_table'; +import { AddPrebuiltRulesHeaderButtons } from './add_prebuilt_rules_header_buttons'; +import { AddPrebuiltRulesTableContextProvider } from './add_prebuilt_rules_table_context'; + +import { useUserData } from '../../../../../detections/components/user_info'; +import { usePrebuiltRulesInstallReview } from '../../../../rule_management/logic/prebuilt_rules/use_prebuilt_rules_install_review'; +import { useFetchPrebuiltRulesStatusQuery } from '../../../../rule_management/api/hooks/prebuilt_rules/use_fetch_prebuilt_rules_status_query'; +import { useIsUpgradingSecurityPackages } from '../../../../rule_management/logic/use_upgrade_security_packages'; + +// Mock components not needed in this test suite +jest.mock('../../../../rule_management/components/rule_details/rule_details_flyout', () => ({ + RuleDetailsFlyout: jest.fn().mockReturnValue(<>), +})); +jest.mock('../rules_changelog_link', () => ({ + RulesChangelogLink: jest.fn().mockReturnValue(<>), +})); +jest.mock('./add_prebuilt_rules_table_filters', () => ({ + AddPrebuiltRulesTableFilters: jest.fn().mockReturnValue(<>), +})); + +jest.mock('../../../../rule_management/logic/prebuilt_rules/use_perform_rule_install', () => ({ + usePerformInstallAllRules: () => ({ + performInstallAll: jest.fn(), + isLoading: false, + }), + usePerformInstallSpecificRules: () => ({ + performInstallSpecific: jest.fn(), + isLoading: false, + }), +})); + +jest.mock('../../../../../common/lib/kibana', () => ({ + useUiSetting$: jest.fn().mockReturnValue([false]), + useKibana: jest.fn().mockReturnValue({ + services: { + docLinks: { links: { siem: { ruleChangeLog: '' } } }, + }, + }), +})); + +jest.mock('../../../../../common/components/links', () => ({ + useGetSecuritySolutionLinkProps: () => + jest.fn().mockReturnValue({ + onClick: jest.fn(), + }), +})); + +jest.mock( + '../../../../rule_management/api/hooks/prebuilt_rules/use_fetch_prebuilt_rules_status_query', + () => ({ + useFetchPrebuiltRulesStatusQuery: jest.fn().mockReturnValue({ + data: { + prebuiltRulesStatus: { + num_prebuilt_rules_total_in_package: 1, + }, + }, + }), + }) +); + +jest.mock('../../../../rule_management/logic/use_upgrade_security_packages', () => ({ + useIsUpgradingSecurityPackages: jest.fn().mockImplementation(() => false), +})); + +jest.mock( + '../../../../rule_management/logic/prebuilt_rules/use_prebuilt_rules_install_review', + () => ({ + usePrebuiltRulesInstallReview: jest.fn().mockReturnValue({ + data: { + rules: [ + { + id: 'rule-1', + name: 'rule-1', + tags: [], + risk_score: 1, + severity: 'low', + }, + ], + stats: { + num_rules_to_install: 1, + tags: [], + }, + }, + isLoading: false, + isFetched: true, + }), + }) +); + +jest.mock('../../../../../detections/components/user_info', () => ({ + useUserData: jest.fn(), +})); + +describe('AddPrebuiltRulesTable', () => { + it('disables `Install all` button if user has no write permissions', async () => { + (useUserData as jest.Mock).mockReturnValue([ + { + loading: false, + canUserCRUD: false, + }, + ]); + + render( + + + + + ); + + const installAllButton = screen.getByTestId('installAllRulesButton'); + + expect(installAllButton).toHaveTextContent('Install all'); + expect(installAllButton).toBeDisabled(); + }); + + it('disables `Install all` button if prebuilt package is being installed', async () => { + (useUserData as jest.Mock).mockReturnValue([ + { + loading: false, + canUserCRUD: true, + }, + ]); + + (useIsUpgradingSecurityPackages as jest.Mock).mockReturnValueOnce(true); + + render( + + + + + ); + + const installAllButton = screen.getByTestId('installAllRulesButton'); + + expect(installAllButton).toHaveTextContent('Install all'); + expect(installAllButton).toBeDisabled(); + }); + + it('enables Install all` button when user has permissions', async () => { + (useUserData as jest.Mock).mockReturnValue([ + { + loading: false, + canUserCRUD: true, + }, + ]); + + render( + + + + + ); + + const installAllButton = screen.getByTestId('installAllRulesButton'); + + expect(installAllButton).toHaveTextContent('Install all'); + expect(installAllButton).toBeEnabled(); + }); + + it.each([ + ['Security:Read', true], + ['Security:Write', false], + ])( + `renders "No rules available for install" when there are no rules to install and user has %s`, + async (_permissions, canUserCRUD) => { + (useUserData as jest.Mock).mockReturnValue([ + { + loading: false, + canUserCRUD, + }, + ]); + + (usePrebuiltRulesInstallReview as jest.Mock).mockReturnValueOnce({ + data: { + rules: [], + stats: { + num_rules_to_install: 0, + tags: [], + }, + }, + isLoading: false, + isFetched: true, + }); + (useFetchPrebuiltRulesStatusQuery as jest.Mock).mockReturnValueOnce({ + data: { + prebuiltRulesStatus: { + num_prebuilt_rules_total_in_package: 0, + }, + }, + }); + + const { findByText } = render( + + + + ); + + expect(await findByText('All Elastic rules have been installed')).toBeInTheDocument(); + } + ); + + it('does not render `Install rule` on rule rows for users with no write permissions', async () => { + (useUserData as jest.Mock).mockReturnValue([ + { + loading: false, + canUserCRUD: false, + }, + ]); + + const id = 'rule-1'; + (usePrebuiltRulesInstallReview as jest.Mock).mockReturnValueOnce({ + data: { + rules: [ + { + id, + rule_id: id, + name: 'rule-1', + tags: [], + risk_score: 1, + severity: 'low', + }, + ], + stats: { + num_rules_to_install: 1, + tags: [], + }, + }, + isLoading: false, + isFetched: true, + }); + (useFetchPrebuiltRulesStatusQuery as jest.Mock).mockReturnValueOnce({ + data: { + prebuiltRulesStatus: { + num_prebuilt_rules_total_in_package: 1, + }, + }, + }); + + render( + + + + ); + + const installRuleButton = screen.queryByTestId(`installSinglePrebuiltRuleButton-${id}`); + + expect(installRuleButton).not.toBeInTheDocument(); + }); + + it('renders `Install rule` on rule rows for users with write permissions', async () => { + (useUserData as jest.Mock).mockReturnValue([ + { + loading: false, + canUserCRUD: true, + }, + ]); + + const id = 'rule-1'; + (usePrebuiltRulesInstallReview as jest.Mock).mockReturnValueOnce({ + data: { + rules: [ + { + id, + rule_id: id, + name: 'rule-1', + tags: [], + risk_score: 1, + severity: 'low', + }, + ], + stats: { + num_rules_to_install: 1, + tags: [], + }, + }, + isLoading: false, + isFetched: true, + }); + (useFetchPrebuiltRulesStatusQuery as jest.Mock).mockReturnValueOnce({ + data: { + prebuiltRulesStatus: { + num_prebuilt_rules_total_in_package: 1, + }, + }, + }); + + render( + + + + ); + + const installRuleButton = screen.queryByTestId(`installSinglePrebuiltRuleButton-${id}`); + + expect(installRuleButton).toBeInTheDocument(); + expect(installRuleButton).toBeEnabled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/add_prebuilt_rules_table_context.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/add_prebuilt_rules_table_context.tsx index f13c8130bf740..ac89ff017c78a 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/add_prebuilt_rules_table_context.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/add_prebuilt_rules_table_context.tsx @@ -24,6 +24,7 @@ import { useRuleDetailsFlyout } from '../../../../rule_management/components/rul import type { RuleResponse } from '../../../../../../common/api/detection_engine/model/rule_schema'; import { RuleDetailsFlyout } from '../../../../rule_management/components/rule_details/rule_details_flyout'; import * as i18n from './translations'; +import { isUpgradeReviewRequestEnabled } from './add_prebuilt_rules_utils'; export interface AddPrebuiltRulesTableState { /** @@ -125,11 +126,11 @@ export const AddPrebuiltRulesTableContextProvider = ({ refetchInterval: 60000, // Refetch available rules for installation every minute keepPreviousData: true, // Use this option so that the state doesn't jump between "success" and "loading" on page change // Fetch rules to install only after background installation of security_detection_rules package is complete - enabled: Boolean( - !isUpgradingSecurityPackages && - prebuiltRulesStatus && - prebuiltRulesStatus.num_prebuilt_rules_total_in_package > 0 - ), + enabled: isUpgradeReviewRequestEnabled({ + canUserCRUD, + isUpgradingSecurityPackages, + prebuiltRulesStatus, + }), }); const { mutateAsync: installAllRulesRequest } = usePerformInstallAllRules(); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/add_prebuilt_rules_utils.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/add_prebuilt_rules_utils.ts new file mode 100644 index 0000000000000..fa032f1b32f6d --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/add_prebuilt_rules_utils.ts @@ -0,0 +1,34 @@ +/* + * 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 type { PrebuiltRulesStatusStats } from '../../../../../../common/api/detection_engine'; + +interface UpgradeReviewEnabledProps { + canUserCRUD: boolean | null; + isUpgradingSecurityPackages: boolean; + prebuiltRulesStatus?: PrebuiltRulesStatusStats; +} + +export const isUpgradeReviewRequestEnabled = ({ + canUserCRUD, + isUpgradingSecurityPackages, + prebuiltRulesStatus, +}: UpgradeReviewEnabledProps) => { + // Wait until security package is updated + if (isUpgradingSecurityPackages) { + return false; + } + + // If user is read-only, allow request to proceed even though the Prebuilt + // Rules might not be installed. For these users, the Fleet endpoint quickly + // fails with 403 so isUpgradingSecurityPackages is false + if (canUserCRUD === false) { + return true; + } + + return prebuiltRulesStatus && prebuiltRulesStatus.num_prebuilt_rules_total_in_package > 0; +};