diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/prebuilt_rules/common/prebuilt_rule_assets_sort.ts b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/prebuilt_rules/common/prebuilt_rule_assets_sort.ts index 3763620a6294c..dc5692b9c1a20 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/prebuilt_rules/common/prebuilt_rule_assets_sort.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/prebuilt_rules/common/prebuilt_rule_assets_sort.ts @@ -11,16 +11,17 @@ import { SortOrder } from '../../model'; export type PrebuiltRuleAssetsSortField = z.infer; export const PrebuiltRuleAssetsSortField = z.enum(['name', 'risk_score', 'severity']); +export type PrebuiltRuleAssetsSortItem = z.infer; +export const PrebuiltRuleAssetsSortItem = z.object({ + /** + * Field to sort by + */ + field: PrebuiltRuleAssetsSortField, + /** + * Sort order + */ + order: SortOrder, +}); + export type PrebuiltRuleAssetsSort = z.infer; -export const PrebuiltRuleAssetsSort = z.array( - z.object({ - /** - * Field to sort by - */ - field: PrebuiltRuleAssetsSortField, - /** - * Sort order - */ - order: SortOrder, - }) -); +export const PrebuiltRuleAssetsSort = z.array(PrebuiltRuleAssetsSortItem); diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/prebuilt_rules/review_rule_installation/review_rule_installation_route.ts b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/prebuilt_rules/review_rule_installation/review_rule_installation_route.ts index 5c8906c0fb4c4..c9889a19c0468 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/prebuilt_rules/review_rule_installation/review_rule_installation_route.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/prebuilt_rules/review_rule_installation/review_rule_installation_route.ts @@ -12,28 +12,26 @@ import { PrebuiltRuleAssetsFilter } from '../common/prebuilt_rule_assets_filter' import { PrebuiltRuleAssetsSort } from '../common/prebuilt_rule_assets_sort'; export type ReviewRuleInstallationRequestBody = z.infer; -export const ReviewRuleInstallationRequestBody = z - .object({ - /** - * Page number starting from 1 - */ - page: z.coerce.number().int().min(1).optional(), - /** - * Rules per page - */ - per_page: z.coerce.number().int().min(1).max(10_000).optional(), - - /** - * Filtering criteria - */ - filter: PrebuiltRuleAssetsFilter.optional(), - - /** - * Sorting criteria - */ - sort: PrebuiltRuleAssetsSort.optional(), - }) - .partial(); +export const ReviewRuleInstallationRequestBody = z.object({ + /** + * Page number starting from 1 + */ + page: z.number().int().min(1).default(1), + /** + * Rules per page + */ + per_page: z.number().int().min(1).max(10_000).default(20), + + /** + * Filtering criteria + */ + filter: PrebuiltRuleAssetsFilter.optional(), + + /** + * Sorting criteria + */ + sort: PrebuiltRuleAssetsSort.optional(), +}); export interface ReviewRuleInstallationResponseBody { /** Current page number */ diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/api.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/api.ts index 56146a3ee1849..d88a0a5556788 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/api.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/api.ts @@ -24,6 +24,7 @@ import type { PerformRuleUpgradeResponseBody, RevertPrebuiltRulesRequest, RevertPrebuiltRulesResponseBody, + ReviewRuleInstallationRequestBody, ReviewRuleInstallationResponseBody, ReviewRuleUpgradeRequestBody, ReviewRuleUpgradeResponseBody, @@ -678,13 +679,16 @@ export const reviewRuleUpgrade = async ({ */ export const reviewRuleInstall = async ({ signal, + request, }: { signal: AbortSignal | undefined; + request: ReviewRuleInstallationRequestBody; }): Promise => KibanaServices.get().http.fetch(REVIEW_RULE_INSTALLATION_URL, { method: 'POST', version: '1', signal, + body: JSON.stringify(request), }); export const performInstallAllRules = async (): Promise => diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/hooks/prebuilt_rules/use_fetch_prebuilt_rules_install_review_query.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/hooks/prebuilt_rules/use_fetch_prebuilt_rules_install_review_query.ts index 2b2ca71b811de..6a21d98b0e8b7 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/hooks/prebuilt_rules/use_fetch_prebuilt_rules_install_review_query.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/hooks/prebuilt_rules/use_fetch_prebuilt_rules_install_review_query.ts @@ -9,7 +9,10 @@ import type { UseQueryOptions } from '@kbn/react-query'; import { useQuery, useQueryClient } from '@kbn/react-query'; import { reviewRuleInstall } from '../../api'; import { REVIEW_RULE_INSTALLATION_URL } from '../../../../../../common/api/detection_engine/prebuilt_rules/urls'; -import type { ReviewRuleInstallationResponseBody } from '../../../../../../common/api/detection_engine/prebuilt_rules'; +import type { + ReviewRuleInstallationRequestBody, + ReviewRuleInstallationResponseBody, +} from '../../../../../../common/api/detection_engine/prebuilt_rules'; import { DEFAULT_QUERY_OPTIONS } from '../constants'; import { retryOnRateLimitedError } from './retry_on_rate_limited_error'; import { cappedExponentialBackoff } from './capped_exponential_backoff'; @@ -17,12 +20,13 @@ import { cappedExponentialBackoff } from './capped_exponential_backoff'; export const REVIEW_RULE_INSTALLATION_QUERY_KEY = ['POST', REVIEW_RULE_INSTALLATION_URL]; export const useFetchPrebuiltRulesInstallReviewQuery = ( + request: ReviewRuleInstallationRequestBody, options?: UseQueryOptions ) => { return useQuery( - REVIEW_RULE_INSTALLATION_QUERY_KEY, + [...REVIEW_RULE_INSTALLATION_QUERY_KEY, request], async ({ signal }) => { - const response = await reviewRuleInstall({ signal }); + const response = await reviewRuleInstall({ signal, request }); return response; }, { diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/logic/prebuilt_rules/use_prebuilt_rules_install_review.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/logic/prebuilt_rules/use_prebuilt_rules_install_review.ts index 2a44607187fa4..1bf048f9975c8 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/logic/prebuilt_rules/use_prebuilt_rules_install_review.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/logic/prebuilt_rules/use_prebuilt_rules_install_review.ts @@ -10,6 +10,16 @@ import type { ReviewRuleInstallationResponseBody } from '../../../../../common/a import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import * as i18n from '../translations'; import { useFetchPrebuiltRulesInstallReviewQuery } from '../../api/hooks/prebuilt_rules/use_fetch_prebuilt_rules_install_review_query'; +import type { AddPrebuiltRulesTableFilterOptions } from '../../../rule_management_ui/components/rules_table/add_prebuilt_rules_table/add_prebuilt_rules_table_context'; +import type { PrebuiltRuleAssetsSortItem } from '../../../../../common/api/detection_engine/prebuilt_rules/common/prebuilt_rule_assets_sort'; +import type { PrebuiltRuleAssetsFilter } from '../../../../../common/api/detection_engine/prebuilt_rules/common/prebuilt_rule_assets_filter'; + +interface UsePrebuiltRulesInstallReviewParams { + page: number; + perPage: number; + filterOptions?: AddPrebuiltRulesTableFilterOptions; + sortingOptions?: PrebuiltRuleAssetsSortItem; +} /** * A wrapper around useQuery provides default values to the underlying query, @@ -18,12 +28,52 @@ import { useFetchPrebuiltRulesInstallReviewQuery } from '../../api/hooks/prebuil * @returns useQuery result */ export const usePrebuiltRulesInstallReview = ( + requestParameters: UsePrebuiltRulesInstallReviewParams, options?: UseQueryOptions ) => { const { addError } = useAppToasts(); - return useFetchPrebuiltRulesInstallReviewQuery({ - onError: (error) => addError(error, { title: i18n.RULE_AND_TIMELINE_FETCH_FAILURE }), - ...options, - }); + return useFetchPrebuiltRulesInstallReviewQuery( + { + page: requestParameters.page, + per_page: requestParameters.perPage, + filter: prepareFilters(requestParameters.filterOptions), + sort: requestParameters.sortingOptions ? [requestParameters.sortingOptions] : undefined, + }, + { + onError: (error) => addError(error, { title: i18n.RULE_AND_TIMELINE_FETCH_FAILURE }), + ...options, + } + ); }; + +/** + * Converts filter options from a simplified UI format to a format expected by the API. + */ +function prepareFilters( + filterOptions: AddPrebuiltRulesTableFilterOptions | undefined +): PrebuiltRuleAssetsFilter | undefined { + if (!filterOptions) { + return undefined; + } + + const filter: PrebuiltRuleAssetsFilter = { + fields: {}, + }; + + if (filterOptions.name) { + filter.fields.name = { + include: { values: [filterOptions.name] }, + }; + } + + if (filterOptions.tags.length) { + filter.fields.tags = { + include: { values: filterOptions.tags }, + }; + } + + const isEmptyFilter = Object.keys(filter.fields).length === 0; + + return isEmptyFilter ? undefined : filter; +} diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/add_prebuilt_rules_table.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/add_prebuilt_rules_table.tsx index c73f7122ad542..0b46b9abf4e51 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/add_prebuilt_rules_table.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/add_prebuilt_rules_table.tsx @@ -7,18 +7,20 @@ import type { CriteriaWithPagination } from '@elastic/eui'; import { - EuiInMemoryTable, EuiSkeletonLoading, EuiProgress, EuiSkeletonTitle, EuiSkeletonText, EuiFlexGroup, EuiFlexItem, + EuiBasicTable, } from '@elastic/eui'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useMemo } from 'react'; +import type { PrebuiltRuleAssetsSortField } from '../../../../../../common/api/detection_engine/prebuilt_rules/common/prebuilt_rule_assets_sort'; +import * as i18n from '../../../pages/add_rules/translations'; import type { RuleResponse } from '../../../../../../common/api/detection_engine/model/rule_schema'; -import { RULES_TABLE_INITIAL_PAGE_SIZE, RULES_TABLE_PAGE_SIZE_OPTIONS } from '../constants'; +import { RULES_TABLE_PAGE_SIZE_OPTIONS } from '../constants'; import { AddPrebuiltRulesTableNoItemsMessage } from './add_prebuilt_rules_no_items_message'; import { useAddPrebuiltRulesTableContext } from './add_prebuilt_rules_table_context'; import { AddPrebuiltRulesTableFilters } from './add_prebuilt_rules_table_filters'; @@ -28,31 +30,53 @@ import { useAddPrebuiltRulesTableColumns } from './use_add_prebuilt_rules_table_ * Table Component for displaying new rules that are available to be installed */ export const AddPrebuiltRulesTable = React.memo(() => { - const addRulesTableContext = useAddPrebuiltRulesTableContext(); - const { state: { rules, hasRulesToInstall, isLoading, + isFetching, isRefetching, selectedRules, isUpgradingSecurityPackages, + pagination, + sortingOptions, }, - actions: { selectRules }, - } = addRulesTableContext; + actions: { setPagination, setSortingOptions, selectRules }, + } = useAddPrebuiltRulesTableContext(); + const rulesColumns = useAddPrebuiltRulesTableColumns(); const shouldShowProgress = isUpgradingSecurityPackages || isRefetching; - const [pageIndex, setPageIndex] = useState(0); const handleTableChange = useCallback( - ({ page: { index } }: CriteriaWithPagination) => { - setPageIndex(index); + ({ page: { index, size }, sort }: CriteriaWithPagination) => { + setPagination({ + page: index + 1, + perPage: size, + }); + + if (sort) { + setSortingOptions({ + field: sort.field as PrebuiltRuleAssetsSortField, + order: sort.direction, + }); + } }, - [setPageIndex] + [setPagination, setSortingOptions] ); + const sortingTableProp = useMemo(() => { + return sortingOptions + ? { + sort: { + field: sortingOptions.field, + direction: sortingOptions.order, + }, + } + : {}; + }, [sortingOptions]); + return ( <> {shouldShowProgress && ( @@ -89,23 +113,26 @@ export const AddPrebuiltRulesTable = React.memo(() => { - true, onSelectionChange: selectRules, initialSelected: selectedRules, }} + sorting={sortingTableProp} itemId="rule_id" data-test-subj="add-prebuilt-rules-table" columns={rulesColumns} - onTableChange={handleTableChange} + onChange={handleTableChange} + tableCaption={i18n.PAGE_TITLE} /> ) diff --git a/x-pack/solutions/security/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/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/add_prebuilt_rules_table_context.tsx index 0d838abede125..0594a71d46d01 100644 --- a/x-pack/solutions/security/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/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/add_prebuilt_rules_table_context.tsx @@ -24,18 +24,15 @@ import { useIsUpgradingSecurityPackages } from '../../../../rule_management/logi import { useRulePreviewFlyout } from '../use_rule_preview_flyout'; import { isUpgradeReviewRequestEnabled } from './add_prebuilt_rules_utils'; import * as i18n from './translations'; -import type { AddPrebuiltRulesTableFilterOptions } from './use_filter_prebuilt_rules_to_install'; -import { useFilterPrebuiltRulesToInstall } from './use_filter_prebuilt_rules_to_install'; +import { RULES_TABLE_INITIAL_PAGE_SIZE } from '../constants'; +import type { PaginationOptions } from '../../../../rule_management/logic'; +import type { PrebuiltRuleAssetsSortItem } from '../../../../../../common/api/detection_engine/prebuilt_rules/common/prebuilt_rule_assets_sort'; export interface AddPrebuiltRulesTableState { /** * Rules available to be installed after applying `filterOptions` */ rules: RuleResponse[]; - /** - * Currently selected table filter - */ - filterOptions: AddPrebuiltRulesTableFilterOptions; /** * All unique tags for all rules */ @@ -48,6 +45,10 @@ export interface AddPrebuiltRulesTableState { * Is true then there is no cached data and the query is currently fetching. */ isLoading: boolean; + /** + * Is true whenever a request is in-flight, which includes initial loading as well as background refetches. + */ + isFetching: boolean; /** * Will be true if the query has been fetched. */ @@ -81,6 +82,24 @@ export interface AddPrebuiltRulesTableState { * Rule rows selected in EUI InMemory Table */ selectedRules: RuleResponse[]; + /** + * Current pagination state + */ + pagination: PaginationOptions; + /** + * Currently selected table sorting + */ + sortingOptions: PrebuiltRuleAssetsSortItem | undefined; + + /** + * Currently selected table filter + */ + filterOptions: AddPrebuiltRulesTableFilterOptions; +} + +export interface AddPrebuiltRulesTableFilterOptions { + name: string; + tags: string[]; } export interface AddPrebuiltRulesTableActions { @@ -90,6 +109,8 @@ export interface AddPrebuiltRulesTableActions { installSelectedRules: (enable?: boolean) => void; setFilterOptions: Dispatch>; selectRules: (rules: RuleResponse[]) => void; + setPagination: Dispatch>; + setSortingOptions: Dispatch>; openRulePreview: (ruleId: RuleSignatureId) => void; } @@ -114,11 +135,29 @@ export const AddPrebuiltRulesTableContextProvider = ({ const [{ loading: userInfoLoading, canUserCRUD }] = useUserData(); - const [filterOptions, setFilterOptions] = useState({ - filter: '', + const [pagination, setPagination] = useState({ + page: 1, + perPage: RULES_TABLE_INITIAL_PAGE_SIZE, + }); + + const [filterOptions, setInternalFilterOptions] = useState({ + name: '', tags: [], }); + const setFilterOptions = useCallback< + Dispatch> + >((action) => { + setInternalFilterOptions(action); + setPagination((prev) => ({ + // Reset pagination to the first page when filters are changed to avoid displaying the wrong page of rules + ...prev, + page: 1, + })); + }, []); + + const [sortingOptions, setSortingOptions] = useState(); + const { data: prebuiltRulesStatus } = useFetchPrebuiltRulesStatusQuery(); const isUpgradingSecurityPackages = useIsUpgradingSecurityPackages(); @@ -128,33 +167,44 @@ export const AddPrebuiltRulesTableContextProvider = ({ }) > 0; const { - data: { rules, stats: { tags } } = { - rules: [], - stats: { tags: [] }, - }, + data: reviewResponse, refetch, dataUpdatedAt, isFetched, + isFetching, isLoading, isRefetching, - } = usePrebuiltRulesInstallReview({ - 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: isUpgradeReviewRequestEnabled({ - canUserCRUD, - isUpgradingSecurityPackages, - prebuiltRulesStatus: prebuiltRulesStatus?.stats, - }), - }); + } = usePrebuiltRulesInstallReview( + { + page: pagination.page, + perPage: pagination.perPage, + filterOptions, + sortingOptions, + }, + { + 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: isUpgradeReviewRequestEnabled({ + canUserCRUD, + isUpgradingSecurityPackages, + prebuiltRulesStatus: prebuiltRulesStatus?.stats, + }), + } + ); + + const rules = useMemo(() => reviewResponse?.rules ?? [], [reviewResponse]); + + const rulesMatchingFilterCount = reviewResponse?.total ?? 0; + const installableRulesCount = reviewResponse?.stats.num_rules_to_install ?? 0; + + const tags = useMemo(() => reviewResponse?.stats?.tags ?? [], [reviewResponse]); const isAnyRuleInstalling = loadingRules.length > 0 || isInstallingAllRules; const { mutateAsync: installAllRulesRequest } = usePerformInstallAllRules(); const { mutateAsync: installSpecificRulesRequest } = usePerformInstallSpecificRules(); - const filteredRules = useFilterPrebuiltRulesToInstall({ filterOptions, rules }); - const installOneRule = useCallback( async (ruleId: RuleSignatureId, enable?: boolean) => { const rule = rules.find((r) => r.rule_id === ruleId); @@ -258,7 +308,7 @@ export const AddPrebuiltRulesTableContextProvider = ({ ); const { rulePreviewFlyout, openRulePreview } = useRulePreviewFlyout({ - rules: filteredRules, + rules, ruleActionsFactory, flyoutProps: { id: PREBUILT_RULE_INSTALL_FLYOUT_ANCHOR, @@ -268,6 +318,8 @@ export const AddPrebuiltRulesTableContextProvider = ({ const actions = useMemo( () => ({ + setPagination, + setSortingOptions, setFilterOptions, installAllRules, installOneRule, @@ -276,18 +328,28 @@ export const AddPrebuiltRulesTableContextProvider = ({ selectRules: setSelectedRules, openRulePreview, }), - [installAllRules, installOneRule, installSelectedRules, refetch, openRulePreview] + [ + setPagination, + setSortingOptions, + installAllRules, + installOneRule, + installSelectedRules, + refetch, + openRulePreview, + setFilterOptions, + ] ); const providerValue = useMemo(() => { return { state: { - rules: filteredRules, + rules, filterOptions, tags, - hasRulesToInstall: isFetched && rules.length > 0, + hasRulesToInstall: installableRulesCount > 0, isFetched, isLoading, + isFetching, loadingRules, isRefetching, isUpgradingSecurityPackages, @@ -295,15 +357,22 @@ export const AddPrebuiltRulesTableContextProvider = ({ isAnyRuleInstalling, selectedRules, lastUpdated: dataUpdatedAt, + pagination: { + ...pagination, + total: rulesMatchingFilterCount, + }, + sortingOptions, }, actions, }; }, [ rules, - filteredRules, filterOptions, tags, + rulesMatchingFilterCount, + installableRulesCount, isFetched, + isFetching, isLoading, loadingRules, isRefetching, @@ -312,6 +381,8 @@ export const AddPrebuiltRulesTableContextProvider = ({ isAnyRuleInstalling, selectedRules, dataUpdatedAt, + pagination, + sortingOptions, actions, ]); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/add_prebuilt_rules_table_filters.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/add_prebuilt_rules_table_filters.tsx index 5640f64cd7a0b..966179a4b183f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/add_prebuilt_rules_table_filters.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/add_prebuilt_rules_table_filters.tsx @@ -31,10 +31,10 @@ const AddPrebuiltRulesTableFiltersComponent = () => { const { tags: selectedTags } = filterOptions; const handleOnSearch = useCallback( - (filterString: string) => { + (nameFilter: string) => { setFilterOptions((filters) => ({ ...filters, - filter: filterString.trim(), + name: nameFilter.trim(), })); }, [setFilterOptions] @@ -55,7 +55,7 @@ const AddPrebuiltRulesTableFiltersComponent = () => { return ( diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/use_filter_prebuilt_rules_to_install.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/use_filter_prebuilt_rules_to_install.ts deleted file mode 100644 index 245d879fa1c51..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/use_filter_prebuilt_rules_to_install.ts +++ /dev/null @@ -1,37 +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 { useMemo } from 'react'; -import type { RuleResponse } from '../../../../../../common/api/detection_engine/model/rule_schema'; -import type { FilterOptions } from '../../../../rule_management/logic/types'; - -export type AddPrebuiltRulesTableFilterOptions = Pick; - -export const useFilterPrebuiltRulesToInstall = ({ - rules, - filterOptions, -}: { - rules: RuleResponse[]; - filterOptions: AddPrebuiltRulesTableFilterOptions; -}) => { - const filteredRules = useMemo(() => { - const { filter, tags } = filterOptions; - return rules.filter((rule) => { - if (filter && !rule.name.toLowerCase().includes(filter.toLowerCase())) { - return false; - } - - if (tags && tags.length > 0) { - return tags.every((tag) => rule.tags.includes(tag)); - } - - return true; - }); - }, [filterOptions, rules]); - - return filteredRules; -}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/review_rule_installation/review_rule_installation_handler.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/review_rule_installation/review_rule_installation_handler.ts index 9dcb10f94cec7..f2494fb0ac4ba 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/review_rule_installation/review_rule_installation_handler.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/review_rule_installation/review_rule_installation_handler.ts @@ -7,6 +7,7 @@ import type { KibanaRequest, KibanaResponseFactory, Logger } from '@kbn/core/server'; import { transformError } from '@kbn/securitysolution-es-utils'; +import type { CamelCasedPropertiesDeep } from 'type-fest'; import type { RuleSummary } from '../../logic/rule_objects/prebuilt_rule_objects_client'; import type { ReviewRuleInstallationRequestBody, @@ -23,37 +24,24 @@ import type { BasicRuleInfo } from '../../logic/basic_rule_info'; import type { MlAuthz } from '../../../../machine_learning/authz'; import type { PrebuiltRuleAssetsFilter } from '../../../../../../common/api/detection_engine/prebuilt_rules/common/prebuilt_rule_assets_filter'; import type { PrebuiltRuleAssetsSort } from '../../../../../../common/api/detection_engine/prebuilt_rules/common/prebuilt_rule_assets_sort'; - -/* - To ensure a smooth transition from a non-paginated API to a paginated API, we will release the changes in two stages: - Release 1: Only the backend and mapping changes. - - Endpoint is paginated, but `page` and `per_page` parameters are optional. If no pagination parameters are provided, it will return all rules at once (same as previous behavior). - - No changes to frontend – sorting and pagination are still handled on the frontend. - Release 2: Frontend changes and making pagination parameters required. - - `page` and `per_page` parameters become required. - - Frontend makes use of backend-side pagination and sorting. -*/ -const DEFAULT_PER_PAGE = 10_000; -const DEFAULT_PAGE = 1; +import type { RuleResponse } from '../../../../../../common/api/detection_engine'; +import { convertObjectKeysToCamelCase } from '../../../../../utils/object_case_converters'; export const reviewRuleInstallationHandler = async ( context: SecuritySolutionRequestHandlerContext, - request: KibanaRequest, + request: KibanaRequest, response: KibanaResponseFactory, logger: Logger ) => { const siemResponse = buildSiemResponse(response); - const { - page = DEFAULT_PAGE, - per_page: perPage = DEFAULT_PER_PAGE, - sort, - filter, - } = request.body ?? {}; + const requestParameters = convertObjectKeysToCamelCase(request.body); logger.debug( - `reviewRuleInstallationHandler: Executing handler with params: page=${page}, perPage=${perPage}, sort=${JSON.stringify( - sort - )}, filter=${JSON.stringify(filter)}` + `reviewRuleInstallationHandler: Executing handler with params: page=${ + requestParameters.page + }, perPage=${requestParameters.perPage}, sort=${JSON.stringify( + requestParameters.sort + )}, filter=${JSON.stringify(requestParameters.filter)}` ); try { @@ -64,27 +52,6 @@ export const reviewRuleInstallationHandler = async ( const ruleObjectsClient = createPrebuiltRuleObjectsClient(rulesClient); const mlAuthz = ctx.securitySolution.getMlAuthz(); - const fetchStats = async (): Promise<{ tags: string[]; numRulesToInstall: number }> => { - // If there's no filter, we can reuse already fetched installable rule versions array - const requestHasFilter = Boolean(Object.keys(filter ?? {}).length); - - const installableVersionsWithoutFilter = requestHasFilter - ? await getInstallableRuleVersions( - ruleAssetsClient, - logger, - mlAuthz, - installedRuleVersionsMap - ) - : installableVersions; - - const tags = await ruleAssetsClient.fetchTagsByVersion(installableVersionsWithoutFilter); - - return { - tags, - numRulesToInstall: installableVersionsWithoutFilter.length, - }; - }; - const installedRuleVersions = await ruleObjectsClient.fetchInstalledRuleVersions(); logger.debug( `reviewRuleInstallationHandler: Found ${installedRuleVersions.length} currently installed prebuilt rules` @@ -93,34 +60,26 @@ export const reviewRuleInstallationHandler = async ( installedRuleVersions.map((version) => [version.rule_id, version]) ); - const installableVersions = await getInstallableRuleVersions( - ruleAssetsClient, - logger, - mlAuthz, - installedRuleVersionsMap, - sort, - filter - ); - - const installableVersionsPage = installableVersions.slice((page - 1) * perPage, page * perPage); - - const installableRuleAssetsPage = await ruleAssetsClient.fetchAssetsByVersion( - installableVersionsPage - ); - - const { tags, numRulesToInstall } = await fetchStats(); + const [rules, stats] = await Promise.all([ + fetchRules({ + ruleAssetsClient, + logger, + mlAuthz, + installedRuleVersionsMap, + requestParameters, + }), + fetchStats({ ruleAssetsClient, logger, mlAuthz, installedRuleVersionsMap }), + ]); const body: ReviewRuleInstallationResponseBody = { - page, - per_page: perPage, - total: installableVersions.length, // Number of rules matching the filter + page: requestParameters.page, + per_page: requestParameters.perPage, + rules: rules.rules, + total: rules.total, // Number of rules matching the filter stats: { - tags, - num_rules_to_install: numRulesToInstall, // Number of installable rules without applying filters + tags: stats.tags, + num_rules_to_install: stats.numRulesToInstall, // Number of installable rules without applying filters }, - rules: installableRuleAssetsPage.map((prebuiltRuleAsset) => - convertPrebuiltRuleAssetToRuleResponse(prebuiltRuleAsset) - ), }; logger.debug( @@ -138,6 +97,69 @@ export const reviewRuleInstallationHandler = async ( } }; +async function fetchRules({ + ruleAssetsClient, + logger, + mlAuthz, + installedRuleVersionsMap, + requestParameters, +}: { + ruleAssetsClient: IPrebuiltRuleAssetsClient; + logger: Logger; + mlAuthz: MlAuthz; + installedRuleVersionsMap: Map; + requestParameters: CamelCasedPropertiesDeep; +}): Promise<{ rules: RuleResponse[]; total: number }> { + const { sort, filter, page, perPage } = requestParameters; + const installableVersions = await getInstallableRuleVersions( + ruleAssetsClient, + logger, + mlAuthz, + installedRuleVersionsMap, + sort, + filter + ); + + const installableVersionsPage = installableVersions.slice((page - 1) * perPage, page * perPage); + + const installableRuleAssetsPage = await ruleAssetsClient.fetchAssetsByVersion( + installableVersionsPage + ); + + return { + rules: installableRuleAssetsPage.map((prebuiltRuleAsset) => + convertPrebuiltRuleAssetToRuleResponse(prebuiltRuleAsset) + ), + total: installableVersions.length, + }; +} + +async function fetchStats({ + ruleAssetsClient, + logger, + mlAuthz, + installedRuleVersionsMap, +}: { + ruleAssetsClient: IPrebuiltRuleAssetsClient; + logger: Logger; + mlAuthz: MlAuthz; + installedRuleVersionsMap: Map; +}): Promise<{ tags: string[]; numRulesToInstall: number }> { + const installableVersionsWithoutFilter = await getInstallableRuleVersions( + ruleAssetsClient, + logger, + mlAuthz, + installedRuleVersionsMap + ); + + const tags = await ruleAssetsClient.fetchTagsByVersion(installableVersionsWithoutFilter); + + return { + tags, + numRulesToInstall: installableVersionsWithoutFilter.length, + }; +} + async function getInstallableRuleVersions( ruleAssetsClient: IPrebuiltRuleAssetsClient, logger: Logger, diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/review_rule_installation/review_rule_installation_route.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/review_rule_installation/review_rule_installation_route.ts index bd553d36dd1c5..ad4158442d770 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/review_rule_installation/review_rule_installation_route.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/review_rule_installation/review_rule_installation_route.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { z } from '@kbn/zod'; import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; import type { Logger } from '@kbn/core/server'; import { REVIEW_RULE_INSTALLATION_URL } from '../../../../../../common/api/detection_engine/prebuilt_rules'; @@ -12,8 +13,8 @@ import { ReviewRuleInstallationRequestBody as ReviewRuleInstallationRequestBodyS import type { SecuritySolutionPluginRouter } from '../../../../../types'; import { routeLimitedConcurrencyTag } from '../../../../../utils/route_limited_concurrency_tag'; import { - PREBUILT_RULES_OPERATION_CONCURRENCY, PREBUILT_RULES_OPERATION_SOCKET_TIMEOUT_MS, + PREBUILT_RULES_INSTALLATION_REVIEW_CONCURRENCY, } from '../../constants'; import { reviewRuleInstallationHandler } from './review_rule_installation_handler'; @@ -31,7 +32,7 @@ export const reviewRuleInstallationRoute = ( }, }, options: { - tags: [routeLimitedConcurrencyTag(PREBUILT_RULES_OPERATION_CONCURRENCY)], + tags: [routeLimitedConcurrencyTag(PREBUILT_RULES_INSTALLATION_REVIEW_CONCURRENCY)], timeout: { idleSocket: PREBUILT_RULES_OPERATION_SOCKET_TIMEOUT_MS, }, @@ -43,9 +44,9 @@ export const reviewRuleInstallationRoute = ( validate: { request: { body: buildRouteValidationWithZod( - // Since the HTTP service converts `undefined` request bodies to null, we need to allow null values. - // This will be removed in the next release when we make pagination parameters required. - ReviewRuleInstallationRequestBodySchema.nullable() + // If the request body is undefined, pass it as an empty object to the schema + // to let the schema add default values. + z.preprocess((data: unknown) => data ?? {}, ReviewRuleInstallationRequestBodySchema) ), }, }, diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/constants.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/constants.ts index 86fd9b45025dc..2134452bf05b3 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/constants.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/constants.ts @@ -18,3 +18,11 @@ export const PREBUILT_RULES_OPERATION_CONCURRENCY = 1; * is expected to be requested much more often than the other prebuilt rules API endpoints. */ export const PREBUILT_RULES_UPGRADE_REVIEW_CONCURRENCY = 3; + +/** + * Prebuilt rules installation review API endpoint max concurrency. + * + * The value 5 was chosen as a result of performance testing the endpoint. + * Related issue: https://github.com/elastic/kibana/issues/241656 + */ +export const PREBUILT_RULES_INSTALLATION_REVIEW_CONCURRENCY = 5; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_assets/prebuilt_rule_assets_client/methods/fetch_latest_versions.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_assets/prebuilt_rule_assets_client/methods/fetch_latest_versions.ts index f27c6567ba3f7..4164b0a3e6f87 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_assets/prebuilt_rule_assets_client/methods/fetch_latest_versions.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_assets/prebuilt_rule_assets_client/methods/fetch_latest_versions.ts @@ -16,6 +16,7 @@ import type { BasicRuleInfo } from '../../../basic_rule_info'; import { PREBUILT_RULE_ASSETS_SO_TYPE } from '../../prebuilt_rule_assets_type'; import type { PrebuiltRuleAssetsFilter } from '../../../../../../../../common/api/detection_engine/prebuilt_rules/common/prebuilt_rule_assets_filter'; import type { PrebuiltRuleAssetsSort } from '../../../../../../../../common/api/detection_engine/prebuilt_rules/common/prebuilt_rule_assets_sort'; +import type { PrebuiltRuleAsset } from '../../../../model/rule_assets/prebuilt_rule_asset'; import { createChunkedFilters, chunkedFetch } from '../utils'; type RuleVersionInfo = BasicRuleInfo & { @@ -24,7 +25,6 @@ type RuleVersionInfo = BasicRuleInfo & { risk_score: number; severity: string; }; -import type { PrebuiltRuleAsset } from '../../../../model/rule_assets/prebuilt_rule_asset'; const SEVERITY_RANK: Record = { low: 20, @@ -133,6 +133,8 @@ export async function fetchLatestVersions( const filteredVersions = filterRuleVersions(latestVersions, filter); + // Since we fetch from ES in chunks, we can't use ES for sorting. + // So we sort here, once all chunks are fetched. return sortRuleVersions(filteredVersions, sort); } @@ -162,31 +164,39 @@ const filterRuleVersions = ( }); }; -const sortRuleVersions = ( +type RuleVersionInfoCompareFn = (a: RuleVersionInfo, b: RuleVersionInfo) => number; + +function sortRuleVersions( versions: RuleVersionInfo[], sort?: PrebuiltRuleAssetsSort -): RuleVersionInfo[] => { +): RuleVersionInfo[] { const sortField = sort?.[0]?.field; + if (!sortField) { return versions; } const order = sort?.[0]?.order ?? 'asc'; + + return versions.sort(createRuleVersionInfoCompareFn(sortField, order)); +} + +function createRuleVersionInfoCompareFn( + sortField: string, + order: 'asc' | 'desc' +): RuleVersionInfoCompareFn | undefined { const direction = order === 'desc' ? -1 : 1; - return versions.sort((a, b) => { - switch (sortField) { - case 'name': - return a.name.localeCompare(b.name) * direction; - case 'risk_score': - return (a.risk_score - b.risk_score) * direction; - case 'severity': { + switch (sortField) { + case 'name': + return (a, b) => a.name.localeCompare(b.name) * direction; + case 'risk_score': + return (a, b) => (a.risk_score - b.risk_score) * direction; + case 'severity': + return (a, b) => { const rankA = SEVERITY_RANK[a.severity] ?? -1; const rankB = SEVERITY_RANK[b.severity] ?? -1; return (rankA - rankB) * direction; - } - default: - return 0; - } - }); -}; + }; + } +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/constants.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/constants.ts index 143f3d30389c8..62341931eea47 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/constants.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/constants.ts @@ -107,4 +107,7 @@ export const FUNCTIONAL_FIELD_MAP: Record, boolean> = { created_by: false, }; +// This constant is used in rule tags aggregations. It limits the maximum number of tags that can be returned by a "terms" aggregation. +// By default, the "terms" aggregation returns only 10 results, so we set it to a high number to ensure we get all tags. +// If there are more than this number of tags, only the first EXPECTED_MAX_TAGS of tags will be returned. export const EXPECTED_MAX_TAGS = 65536; diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/common/install_prebuilt_rules/review_installation.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/common/install_prebuilt_rules/review_installation.ts index ff274bbe978bc..c5f56142a75e3 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/common/install_prebuilt_rules/review_installation.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/common/install_prebuilt_rules/review_installation.ts @@ -76,62 +76,31 @@ export default ({ getService }: FtrProviderContext): void => { }); describe('Parameters defaults', () => { - it('called without parameters - returns all rules', async () => { - const ruleAssets = [ - createRuleAssetSavedObject({ rule_id: 'rule-1', name: 'Rule 1' }), - createRuleAssetSavedObject({ rule_id: 'rule-2', name: 'Rule 2' }), - ]; - - await createPrebuiltRuleAssetSavedObjects(es, ruleAssets); - + it('called without parameters - defaults to page 1 and per_page 20', async () => { const response = await reviewPrebuiltRulesToInstall(supertest); expect(response).toMatchObject({ - rules: [ - expect.objectContaining({ rule_id: 'rule-1', name: 'Rule 1' }), - expect.objectContaining({ rule_id: 'rule-2', name: 'Rule 2' }), - ], page: 1, - per_page: 10_000, + per_page: 20, }); }); - it('called with an empty object - returns all rules', async () => { - const ruleAssets = [ - createRuleAssetSavedObject({ rule_id: 'rule-1', name: 'Rule 1' }), - createRuleAssetSavedObject({ rule_id: 'rule-2', name: 'Rule 2' }), - ]; - - await createPrebuiltRuleAssetSavedObjects(es, ruleAssets); - - const response = await reviewPrebuiltRulesToInstall(supertest, {}); + it('called with an empty object - defaults to page 1 and per_page 20', async () => { + const response = await reviewPrebuiltRulesToInstall(supertest); expect(response).toMatchObject({ - rules: [ - expect.objectContaining({ rule_id: 'rule-1', name: 'Rule 1' }), - expect.objectContaining({ rule_id: 'rule-2', name: 'Rule 2' }), - ], page: 1, - per_page: 10_000, + per_page: 20, }); }); it('called with `per_page` only - respects `per_page` parameter', async () => { - const ruleAssets = [ - createRuleAssetSavedObject({ rule_id: 'rule-1', name: 'Rule 1' }), - createRuleAssetSavedObject({ rule_id: 'rule-2', name: 'Rule 2' }), - ]; - - await createPrebuiltRuleAssetSavedObjects(es, ruleAssets); - const response = await reviewPrebuiltRulesToInstall(supertest, { - per_page: 1, + per_page: 100, }); expect(response).toMatchObject({ - rules: [expect.objectContaining({ rule_id: 'rule-1', name: 'Rule 1' })], - page: 1, - per_page: 1, + per_page: 100, }); }); }); @@ -189,24 +158,26 @@ export default ({ getService }: FtrProviderContext): void => { per_page: 2, }); - expect(page1Response).toMatchObject({ - rules: [ + expect(page1Response.rules).toHaveLength(2); + expect(page1Response.rules).toEqual( + expect.arrayContaining([ expect.objectContaining({ rule_id: 'rule-1' }), expect.objectContaining({ rule_id: 'rule-2' }), - ], - }); + ]) + ); const page2Response = await reviewPrebuiltRulesToInstall(supertest, { page: 2, per_page: 2, }); - expect(page2Response).toMatchObject({ - rules: [ + expect(page2Response.rules).toHaveLength(2); + expect(page2Response.rules).toEqual( + expect.arrayContaining([ expect.objectContaining({ rule_id: 'rule-3' }), expect.objectContaining({ rule_id: 'rule-4' }), - ], - }); + ]) + ); }); it('returns correct number of rules for the last page', async () => { @@ -227,39 +198,79 @@ export default ({ getService }: FtrProviderContext): void => { describe('error handling', () => { it('rejects invalid "page" parameter', async () => { - const invalidPageValues = ['', 0, -1] as number[]; - - for (const value of invalidPageValues) { - expect( - await reviewPrebuiltRulesToInstall( - supertest, - { - page: value, - }, - 400 - ) - ).toMatchObject({ - message: '[request body]: page: Number must be greater than or equal to 1', - }); - } + expect( + await reviewPrebuiltRulesToInstall( + supertest, + { + page: '' as unknown as number, + }, + 400 + ) + ).toMatchObject({ + message: '[request body]: page: Expected number, received string', + }); + + expect( + await reviewPrebuiltRulesToInstall( + supertest, + { + page: 0, + }, + 400 + ) + ).toMatchObject({ + message: '[request body]: page: Number must be greater than or equal to 1', + }); + + expect( + await reviewPrebuiltRulesToInstall( + supertest, + { + page: -1, + }, + 400 + ) + ).toMatchObject({ + message: '[request body]: page: Number must be greater than or equal to 1', + }); }); it('rejects invalid "per_page" parameter', async () => { - const invalidPerPageValues = ['', 0, -1] as number[]; - - for (const value of invalidPerPageValues) { - expect( - await reviewPrebuiltRulesToInstall( - supertest, - { - per_page: value, - }, - 400 - ) - ).toMatchObject({ - message: '[request body]: per_page: Number must be greater than or equal to 1', - }); - } + expect( + await reviewPrebuiltRulesToInstall( + supertest, + { + per_page: '' as unknown as number, + }, + 400 + ) + ).toMatchObject({ + message: '[request body]: per_page: Expected number, received string', + }); + + expect( + await reviewPrebuiltRulesToInstall( + supertest, + { + per_page: 0, + }, + 400 + ) + ).toMatchObject({ + message: '[request body]: per_page: Number must be greater than or equal to 1', + }); + + expect( + await reviewPrebuiltRulesToInstall( + supertest, + { + per_page: -1, + }, + 400 + ) + ).toMatchObject({ + message: '[request body]: per_page: Number must be greater than or equal to 1', + }); expect( await reviewPrebuiltRulesToInstall( @@ -474,11 +485,14 @@ export default ({ getService }: FtrProviderContext): void => { const response = await reviewPrebuiltRulesToInstall(supertest); - expect(response).toMatchObject({ - rules: [ + expect(response.rules).toHaveLength(2); + expect(response.rules).toEqual( + expect.arrayContaining([ expect.objectContaining({ rule_id: 'rule-1', name: 'Rule 1' }), expect.objectContaining({ rule_id: 'rule-2', name: 'Rule 2' }), - ], + ]) + ); + expect(response).toMatchObject({ total: 2, stats: { num_rules_to_install: 2, @@ -499,10 +513,13 @@ export default ({ getService }: FtrProviderContext): void => { filter: { fields: { name: {} } }, }); - expect(emptyNameResponse.rules).toEqual([ - expect.objectContaining({ rule_id: 'rule-1', name: 'Rule 1' }), - expect.objectContaining({ rule_id: 'rule-2', name: 'Rule 2' }), - ]); + expect(emptyNameResponse.rules).toHaveLength(2); + expect(emptyNameResponse.rules).toEqual( + expect.arrayContaining([ + expect.objectContaining({ rule_id: 'rule-1', name: 'Rule 1' }), + expect.objectContaining({ rule_id: 'rule-2', name: 'Rule 2' }), + ]) + ); }); it('with empty include array', async () => { @@ -516,10 +533,13 @@ export default ({ getService }: FtrProviderContext): void => { filter: { fields: { name: { include: { values: [] } } } }, }); - expect(emptyNameResponse.rules).toEqual([ - expect.objectContaining({ rule_id: 'rule-1', name: 'Rule 1' }), - expect.objectContaining({ rule_id: 'rule-2', name: 'Rule 2' }), - ]); + expect(emptyNameResponse.rules).toHaveLength(2); + expect(emptyNameResponse.rules).toEqual( + expect.arrayContaining([ + expect.objectContaining({ rule_id: 'rule-1', name: 'Rule 1' }), + expect.objectContaining({ rule_id: 'rule-2', name: 'Rule 2' }), + ]) + ); }); it('with empty string in include array', async () => { @@ -533,10 +553,13 @@ export default ({ getService }: FtrProviderContext): void => { filter: { fields: { name: { include: { values: [''] } } } }, }); - expect(emptyNameResponse.rules).toEqual([ - expect.objectContaining({ rule_id: 'rule-1', name: 'Rule 1' }), - expect.objectContaining({ rule_id: 'rule-2', name: 'Rule 2' }), - ]); + expect(emptyNameResponse.rules).toHaveLength(2); + expect(emptyNameResponse.rules).toEqual( + expect.arrayContaining([ + expect.objectContaining({ rule_id: 'rule-1', name: 'Rule 1' }), + expect.objectContaining({ rule_id: 'rule-2', name: 'Rule 2' }), + ]) + ); }); }); @@ -570,11 +593,14 @@ export default ({ getService }: FtrProviderContext): void => { filter: { fields: { name: { include: { values: ['rule'] } } } }, }); - expect(response.rules).toEqual([ - expect.objectContaining({ rule_id: 'rule-1', name: 'My rule 1' }), - expect.objectContaining({ rule_id: 'rule-2', name: 'My rule 2' }), - expect.objectContaining({ rule_id: 'rule-3', name: 'My rule 3' }), - ]); + expect(response.rules).toHaveLength(3); + expect(response.rules).toEqual( + expect.arrayContaining([ + expect.objectContaining({ rule_id: 'rule-1', name: 'My rule 1' }), + expect.objectContaining({ rule_id: 'rule-2', name: 'My rule 2' }), + expect.objectContaining({ rule_id: 'rule-3', name: 'My rule 3' }), + ]) + ); }); it('matches case-insensitively', async () => { @@ -615,16 +641,19 @@ export default ({ getService }: FtrProviderContext): void => { filter: { fields: { tags: { include: { values: [] } } } }, }); - expect(emptyTagResponse.rules).toEqual([ - expect.objectContaining({ - rule_id: 'rule-1', - tags: ['tag-a', 'tag-b'], - }), - expect.objectContaining({ - rule_id: 'rule-2', - tags: ['tag-b', 'tag-c'], - }), - ]); + expect(emptyTagResponse.rules).toHaveLength(2); + expect(emptyTagResponse.rules).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + rule_id: 'rule-1', + tags: ['tag-a', 'tag-b'], + }), + expect.objectContaining({ + rule_id: 'rule-2', + tags: ['tag-b', 'tag-c'], + }), + ]) + ); }); }); @@ -650,16 +679,19 @@ export default ({ getService }: FtrProviderContext): void => { filter: { fields: { tags: { include: { values: ['tag-b'] } } } }, }); - expect(singleTagResponse.rules).toEqual([ - expect.objectContaining({ - rule_id: 'rule-1', - tags: ['tag-a', 'tag-b'], - }), - expect.objectContaining({ - rule_id: 'rule-2', - tags: ['tag-b', 'tag-c'], - }), - ]); + expect(singleTagResponse.rules).toHaveLength(2); + expect(singleTagResponse.rules).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + rule_id: 'rule-1', + tags: ['tag-a', 'tag-b'], + }), + expect.objectContaining({ + rule_id: 'rule-2', + tags: ['tag-b', 'tag-c'], + }), + ]) + ); }); it('returns empty array if no matches are found', async () => { diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/oom_testing/install_prebuilt_rules/install_prebuilt_rules.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/oom_testing/install_prebuilt_rules/install_prebuilt_rules.ts index 871bd075d721c..e5b82fe66c855 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/oom_testing/install_prebuilt_rules/install_prebuilt_rules.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/oom_testing/install_prebuilt_rules/install_prebuilt_rules.ts @@ -107,7 +107,10 @@ export default ({ getService }: FtrProviderContext): void => { .set('kbn-xsrf', 'true') .set(ELASTIC_HTTP_VERSION_HEADER, '1') .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') - .send() + .send({ + page: 1, + per_page: 10_000, + }) .expect(200); expect(reviewPrebuiltRulesForInstallationResponse.rules.length).toBeGreaterThan(0); diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/review_install_prebuilt_rules.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/review_install_prebuilt_rules.ts index e068309d0a609..24bd9d4e60d6a 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/review_install_prebuilt_rules.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/review_install_prebuilt_rules.ts @@ -20,7 +20,7 @@ import type SuperTest from 'supertest'; */ export const reviewPrebuiltRulesToInstall = async ( supertest: SuperTest.Agent, - body?: ReviewRuleInstallationRequestBody, + body?: Partial, expectedStatusCode: number = 200 ): Promise => { const response = await supertest diff --git a/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/prebuilt_rules/installation/install_table.cy.ts b/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/prebuilt_rules/installation/install_table.cy.ts new file mode 100644 index 0000000000000..d27be32d45e3a --- /dev/null +++ b/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/prebuilt_rules/installation/install_table.cy.ts @@ -0,0 +1,259 @@ +/* + * 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 { createRuleAssetSavedObject } from '../../../../../helpers/rules'; +import { ADD_ELASTIC_RULES_TABLE } from '../../../../../screens/alerts_detection_rules'; +import { + expectFirstRuleInTable, + expectLastRuleInTable, + expectToContainRule, + expectVisibleRulesCount, + filterBySearchTerm, + filterByTags, +} from '../../../../../tasks/alerts_detection_rules'; +import { installPrebuiltRuleAssets } from '../../../../../tasks/api_calls/prebuilt_rules'; +import { login } from '../../../../../tasks/login'; + +import { deletePrebuiltRulesAssets } from '../../../../../tasks/api_calls/common'; +import { visitAddRulesPage } from '../../../../../tasks/rules_management'; +import { + expectTablePage, + expectTableSorting, + goToTablePage, + setRowsPerPageTo, + sortByTableColumn, +} from '../../../../../tasks/table_pagination'; + +describe( + 'Detection rules, Prebuilt Rules Installation Table', + { tags: ['@ess', '@serverless', '@skipInServerlessMKI'] }, + () => { + describe('Pagination', () => { + beforeEach(() => { + login(); + deletePrebuiltRulesAssets(); + }); + + it('User can paginate over prebuilt rules on Rule Installation page', () => { + const rules = Array.from({ length: 6 }, (_, i) => + createRuleAssetSavedObject({ + name: `Rule ${i + 1}`, + rule_id: `rule_${i + 1}`, + }) + ); + + installPrebuiltRuleAssets(rules); + visitAddRulesPage(); + setRowsPerPageTo(5); + + expectTablePage(1); + expectVisibleRulesCount(ADD_ELASTIC_RULES_TABLE, 5); + // Verify rules 1-5 exist on page 1 (without asserting order) + for (let i = 1; i <= 5; i++) { + expectToContainRule(ADD_ELASTIC_RULES_TABLE, `Rule ${i}`); + } + + goToTablePage(2); + expectTablePage(2); + expectVisibleRulesCount(ADD_ELASTIC_RULES_TABLE, 1); + }); + }); + + describe('Sorting', () => { + beforeEach(() => { + login(); + deletePrebuiltRulesAssets(); + }); + + it('User can sort prebuilt rules on Rule Installation page', () => { + const rules = Array.from({ length: 3 }, (_, i) => + createRuleAssetSavedObject({ + name: `Rule ${i + 1}`, + rule_id: `rule_${i + 1}`, + }) + ); + + installPrebuiltRuleAssets(rules); + visitAddRulesPage(); + + sortByTableColumn('Rule', 'desc'); + expectTableSorting('Rule', 'desc'); + + expectFirstRuleInTable(ADD_ELASTIC_RULES_TABLE, 'Rule 3'); + expectLastRuleInTable(ADD_ELASTIC_RULES_TABLE, 'Rule 1'); + + sortByTableColumn('Rule', 'asc'); + expectTableSorting('Rule', 'asc'); + + expectFirstRuleInTable(ADD_ELASTIC_RULES_TABLE, 'Rule 1'); + expectLastRuleInTable(ADD_ELASTIC_RULES_TABLE, 'Rule 3'); + }); + + it('Navigating to the next page maintains the sorting order', () => { + const rules = Array.from({ length: 6 }, (_, i) => + createRuleAssetSavedObject({ + name: `Rule ${i + 1}`, + rule_id: `rule_${i + 1}`, + }) + ); + + installPrebuiltRuleAssets(rules); + visitAddRulesPage(); + setRowsPerPageTo(5); + + sortByTableColumn('Rule', 'desc'); + expectTableSorting('Rule', 'desc'); + + expectFirstRuleInTable(ADD_ELASTIC_RULES_TABLE, 'Rule 6'); + + goToTablePage(2); + + expectTableSorting('Rule', 'desc'); + expectLastRuleInTable(ADD_ELASTIC_RULES_TABLE, 'Rule 1'); + }); + + it('Changing sorting resets pagination to the first page', () => { + const rules = Array.from({ length: 6 }, (_, i) => + createRuleAssetSavedObject({ + name: `Rule ${i + 1}`, + rule_id: `rule_${i + 1}`, + }) + ); + + installPrebuiltRuleAssets(rules); + visitAddRulesPage(); + setRowsPerPageTo(5); + + sortByTableColumn('Rule', 'desc'); + expectTableSorting('Rule', 'desc'); + + goToTablePage(2); + + sortByTableColumn('Rule', 'asc'); + expectTableSorting('Rule', 'asc'); + + expectTablePage(1); + }); + }); + + describe('Filtering', () => { + beforeEach(() => { + login(); + deletePrebuiltRulesAssets(); + }); + + it('User can filter prebuilt rules by rule name on the Rule Installation page', () => { + const rules = ['My Windows rule', 'My Linux rule'].map((name) => + createRuleAssetSavedObject({ name, rule_id: `rule_${name}` }) + ); + + installPrebuiltRuleAssets(rules); + visitAddRulesPage(); + + filterBySearchTerm('My Windows rule'); + expectVisibleRulesCount(ADD_ELASTIC_RULES_TABLE, 1); + expectFirstRuleInTable(ADD_ELASTIC_RULES_TABLE, 'My Windows rule'); + }); + + it('User can filter prebuilt rules by multiple tags using AND logic on the Rule Installation page', () => { + const rules = [ + createRuleAssetSavedObject({ + name: 'My Windows rule', + rule_id: `rule_1`, + tags: ['tag-a', 'tag-b'], + }), + createRuleAssetSavedObject({ + name: 'My Linux rule', + rule_id: `rule_2`, + tags: ['tag-b', 'tag-c'], + }), + createRuleAssetSavedObject({ + name: 'My macOS rule', + rule_id: `rule_3`, + tags: ['tag-c'], + }), + ]; + + installPrebuiltRuleAssets(rules); + visitAddRulesPage(); + + filterByTags(['tag-b', 'tag-c']); + + expectVisibleRulesCount(ADD_ELASTIC_RULES_TABLE, 1); + expectFirstRuleInTable(ADD_ELASTIC_RULES_TABLE, 'My Linux rule'); + }); + + it('User can sort filtered prebuilt rules', () => { + const rules = [ + createRuleAssetSavedObject({ + name: 'My rule 1', + rule_id: 'rule_1', + tags: ['tag-a', 'tag-b'], + severity: 'high', + }), + createRuleAssetSavedObject({ + name: 'My rule 2', + rule_id: 'rule_2', + tags: ['tag-b', 'tag-c'], + severity: 'low', + }), + createRuleAssetSavedObject({ + name: 'My rule 3', + rule_id: 'rule_3', + tags: ['tag-c'], + severity: 'medium', + }), + ]; + + installPrebuiltRuleAssets(rules); + visitAddRulesPage(); + + filterByTags(['tag-b']); + + sortByTableColumn('Severity', 'asc'); + expectTableSorting('Severity', 'asc'); + + expectVisibleRulesCount(ADD_ELASTIC_RULES_TABLE, 2); + expectFirstRuleInTable(ADD_ELASTIC_RULES_TABLE, 'My rule 2'); + expectLastRuleInTable(ADD_ELASTIC_RULES_TABLE, 'My rule 1'); + }); + + it('Setting a filter resets pagination to the first page', () => { + const rules = Array.from({ length: 6 }, (_, i) => + createRuleAssetSavedObject({ + name: `Rule ${i + 1}`, + rule_id: `rule_${i + 1}`, + tags: ['tag-a'], + }) + ); + + installPrebuiltRuleAssets(rules); + visitAddRulesPage(); + setRowsPerPageTo(5); + + goToTablePage(2); + expectTablePage(2); + + filterByTags(['tag-a']); + expectTablePage(1); + }); + + it('Empty state is shown when filters match no rules', () => { + installPrebuiltRuleAssets([ + createRuleAssetSavedObject({ + name: 'My rule 1', + rule_id: 'rule_1', + }), + ]); + + visitAddRulesPage(); + filterBySearchTerm('no such rules'); + cy.get(ADD_ELASTIC_RULES_TABLE).contains('No items found'); + }); + }); + } +); diff --git a/x-pack/solutions/security/test/security_solution_cypress/cypress/tasks/alerts_detection_rules.ts b/x-pack/solutions/security/test/security_solution_cypress/cypress/tasks/alerts_detection_rules.ts index 80acbe699ca53..469ef3e80dce1 100644 --- a/x-pack/solutions/security/test/security_solution_cypress/cypress/tasks/alerts_detection_rules.ts +++ b/x-pack/solutions/security/test/security_solution_cypress/cypress/tasks/alerts_detection_rules.ts @@ -61,6 +61,7 @@ import { TOASTER_BODY, } from '../screens/alerts_detection_rules'; import type { + ADD_ELASTIC_RULES_TABLE, RULES_MONITORING_TABLE, RULES_UPDATES_TABLE, } from '../screens/alerts_detection_rules'; @@ -412,13 +413,26 @@ export const expectToContainRule = ( tableSelector: | typeof RULES_MANAGEMENT_TABLE | typeof RULES_MONITORING_TABLE - | typeof RULES_UPDATES_TABLE, + | typeof RULES_UPDATES_TABLE + | typeof ADD_ELASTIC_RULES_TABLE, ruleName: string ) => { cy.log(`Expecting rules table to contain '${ruleName}'`); cy.get(tableSelector).find(RULES_ROW).should('include.text', ruleName); }; +export const expectVisibleRulesCount = ( + tableSelector: + | typeof RULES_MANAGEMENT_TABLE + | typeof RULES_MONITORING_TABLE + | typeof RULES_UPDATES_TABLE + | typeof ADD_ELASTIC_RULES_TABLE, + expectedCount: number +) => { + cy.log(`Expecting rules table page to contain ${expectedCount} rules`); + cy.get(tableSelector).find(RULES_ROW).should('have.length', expectedCount); +}; + export const expectModifiedRuleBadgeToBeDisplayed = () => { cy.get(MODIFIED_PREBUILT_RULE_BADGE).should('exist'); }; @@ -462,6 +476,28 @@ export const expectRulesInTable = ( } }; +export const expectFirstRuleInTable = ( + tableSelector: + | typeof RULES_MANAGEMENT_TABLE + | typeof RULES_MONITORING_TABLE + | typeof RULES_UPDATES_TABLE + | typeof ADD_ELASTIC_RULES_TABLE, + ruleName: string +): void => { + cy.get(tableSelector).find(RULES_ROW).first().should('contain.text', ruleName); +}; + +export const expectLastRuleInTable = ( + tableSelector: + | typeof RULES_MANAGEMENT_TABLE + | typeof RULES_MONITORING_TABLE + | typeof RULES_UPDATES_TABLE + | typeof ADD_ELASTIC_RULES_TABLE, + ruleName: string +): void => { + cy.get(tableSelector).find(RULES_ROW).last().should('contain.text', ruleName); +}; + export const expectToContainModifiedBadge = (ruleName: string) => { cy.get(RULES_MANAGEMENT_TABLE) .find(RULES_ROW)