diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/save_with_errors_confirmation/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/save_with_errors_confirmation/index.tsx
new file mode 100644
index 000000000000..3f14945bedad
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/save_with_errors_confirmation/index.tsx
@@ -0,0 +1,53 @@
+/*
+ * 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 { EuiConfirmModal, EuiSpacer, EuiText } from '@elastic/eui';
+
+import * as i18n from './translations';
+
+interface SaveWithErrorsModalProps {
+ errors: string[];
+ onCancel: () => void;
+ onConfirm: () => void;
+}
+
+const SaveWithErrorsModalComponent = ({
+ errors,
+ onCancel,
+ onConfirm,
+}: SaveWithErrorsModalProps) => {
+ return (
+
+ <>
+ {i18n.SAVE_WITH_ERRORS_MODAL_MESSAGE(errors.length)}
+
+
+ {errors.map((validationError, idx) => {
+ return (
+ -
+ {validationError}
+
+ );
+ })}
+
+ >
+
+ );
+};
+
+export const SaveWithErrorsModal = React.memo(SaveWithErrorsModalComponent);
+SaveWithErrorsModal.displayName = 'SaveWithErrorsModal';
diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/save_with_errors_confirmation/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/save_with_errors_confirmation/translations.ts
new file mode 100644
index 000000000000..e470b06c7e82
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/save_with_errors_confirmation/translations.ts
@@ -0,0 +1,36 @@
+/*
+ * 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 { i18n } from '@kbn/i18n';
+
+export const SAVE_WITH_ERRORS_MODAL_TITLE = i18n.translate(
+ 'xpack.securitySolution.detectionEngine.createRule.saveWithErrorsModalTitle',
+ {
+ defaultMessage: 'This rule has validation errors',
+ }
+);
+
+export const SAVE_WITH_ERRORS_CANCEL_BUTTON = i18n.translate(
+ 'xpack.securitySolution.detectionEngine.createRule.saveWithErrorsCancelButton',
+ {
+ defaultMessage: 'Cancel',
+ }
+);
+
+export const SAVE_WITH_ERRORS_CONFIRM_BUTTON = i18n.translate(
+ 'xpack.securitySolution.detectionEngine.createRule.saveWithErrorsConfirmButton',
+ {
+ defaultMessage: 'Confirm',
+ }
+);
+
+export const SAVE_WITH_ERRORS_MODAL_MESSAGE = (errorsCount: number) =>
+ i18n.translate('xpack.securitySolution.detectionEngine.createRule.saveWithErrorsModalMessage', {
+ defaultMessage:
+ 'This rule has {errorsCount} validation {errorsCount, plural, one {error} other {errors}} which can lead to failed rule executions, save anyway?',
+ values: { errorsCount },
+ });
diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/form.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/form.tsx
index 690dedafaf85..d7e3f4566a88 100644
--- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/form.tsx
+++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/form.tsx
@@ -5,7 +5,7 @@
* 2.0.
*/
-import { useState, useMemo, useEffect } from 'react';
+import { useState, useMemo, useEffect, useCallback } from 'react';
import type { DataViewBase } from '@kbn/es-query';
import { isThreatMatchRule } from '../../../../common/detection_engine/utils';
import type {
@@ -16,6 +16,7 @@ import type {
} from '../../../detections/pages/detection_engine/rules/types';
import { DataSourceType } from '../../../detections/pages/detection_engine/rules/types';
import { useKibana } from '../../../common/lib/kibana';
+import type { FormHook, ValidationError } from '../../../shared_imports';
import { useForm, useFormData } from '../../../shared_imports';
import { schema as defineRuleSchema } from '../components/step_define_rule/schema';
import type { EqlOptionsSelected } from '../../../../common/search_strategy';
@@ -26,6 +27,9 @@ import {
import { schema as scheduleRuleSchema } from '../components/step_schedule_rule/schema';
import { getSchema as getActionsRuleSchema } from '../../rule_creation/components/step_rule_actions/get_schema';
import { useFetchIndex } from '../../../common/containers/source';
+import { ERROR_CODES as ESQL_ERROR_CODES } from '../../rule_creation/logic/esql_validator';
+import { EQL_ERROR_CODES } from '../../../common/hooks/eql/api';
+import * as i18n from './translations';
export interface UseRuleFormsProps {
defineStepDefault: DefineStepRule;
@@ -156,3 +160,84 @@ export const useRuleIndexPattern = ({
}, [dataSourceType, isIndexPatternLoading, data, dataViewId, initIndexPattern]);
return { indexPattern, isIndexPatternLoading, browserFields };
};
+
+export interface UseRuleFormsErrors {
+ defineStepForm?: FormHook;
+ aboutStepForm?: FormHook;
+ scheduleStepForm?: FormHook;
+ actionsStepForm?: FormHook;
+}
+
+const getFieldErrorMessages = (fieldError: ValidationError) => {
+ if (fieldError.message.length > 0) {
+ return [fieldError.message];
+ } else if (Array.isArray(fieldError.messages)) {
+ return fieldError.messages.map((message) => JSON.stringify(message));
+ }
+ return [];
+};
+
+const NON_BLOCKING_QUERY_BAR_ERROR_CODES = [
+ ESQL_ERROR_CODES.INVALID_ESQL,
+ EQL_ERROR_CODES.FAILED_REQUEST,
+ EQL_ERROR_CODES.INVALID_EQL,
+ EQL_ERROR_CODES.MISSING_DATA_SOURCE,
+];
+
+const isNonBlockingQueryBarErrorCode = (errorCode?: string) => {
+ return !!NON_BLOCKING_QUERY_BAR_ERROR_CODES.find((code) => code === errorCode);
+};
+
+const NON_BLOCKING_ERROR_CODES = [...NON_BLOCKING_QUERY_BAR_ERROR_CODES];
+
+const isNonBlockingErrorCode = (errorCode?: string) => {
+ return !!NON_BLOCKING_ERROR_CODES.find((code) => code === errorCode);
+};
+
+const transformValidationError = ({
+ errorCode,
+ errorMessage,
+}: {
+ errorCode?: string;
+ errorMessage: string;
+}) => {
+ if (isNonBlockingQueryBarErrorCode(errorCode)) {
+ return i18n.QUERY_BAR_VALIDATION_ERROR(errorMessage);
+ }
+ return errorMessage;
+};
+
+export const useRuleFormsErrors = () => {
+ const getRuleFormsErrors = useCallback(
+ ({ defineStepForm, aboutStepForm, scheduleStepForm, actionsStepForm }: UseRuleFormsErrors) => {
+ const blockingErrors: string[] = [];
+ const nonBlockingErrors: string[] = [];
+
+ for (const [_, fieldHook] of Object.entries(defineStepForm?.getFields() ?? {})) {
+ fieldHook.errors.forEach((fieldError) => {
+ const messages = getFieldErrorMessages(fieldError);
+ if (isNonBlockingErrorCode(fieldError.code)) {
+ nonBlockingErrors.push(
+ ...messages.map((message) =>
+ transformValidationError({ errorCode: fieldError.code, errorMessage: message })
+ )
+ );
+ } else {
+ blockingErrors.push(...messages);
+ }
+ });
+ }
+
+ const blockingForms = [aboutStepForm, scheduleStepForm, actionsStepForm];
+ blockingForms.forEach((form) => {
+ for (const [_, fieldHook] of Object.entries(form?.getFields() ?? {})) {
+ blockingErrors.push(...fieldHook.errors.map((fieldError) => fieldError.message));
+ }
+ });
+ return { blockingErrors, nonBlockingErrors };
+ },
+ []
+ );
+
+ return { getRuleFormsErrors };
+};
diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/index.tsx
index 6e93d6927b6b..97b89630392d 100644
--- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/index.tsx
+++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/index.tsx
@@ -80,8 +80,9 @@ import { RulePreview } from '../../components/rule_preview';
import { getIsRulePreviewDisabled } from '../../components/rule_preview/helpers';
import { useStartMlJobs } from '../../../rule_management/logic/use_start_ml_jobs';
import { NextStep } from '../../components/next_step';
-import { useRuleForms, useRuleIndexPattern } from '../form';
+import { useRuleForms, useRuleFormsErrors, useRuleIndexPattern } from '../form';
import { CustomHeaderPageMemo } from '..';
+import { SaveWithErrorsModal } from '../../components/save_with_errors_confirmation';
const MyEuiPanel = styled(EuiPanel)<{
zindex?: number;
@@ -210,6 +211,12 @@ const CreateRulePageComponent: React.FC = () => {
const [isQueryBarValid, setIsQueryBarValid] = useState(false);
const [isThreatQueryBarValid, setIsThreatQueryBarValid] = useState(false);
+ const [isSaveWithErrorsModalVisible, setIsSaveWithErrorsModalVisible] = useState(false);
+ const [enableRuleAfterConfirmation, setEnableRuleAfterConfirmation] = useState(false);
+ const [nonBlockingRuleErrors, setNonBlockingRuleErrors] = useState([]);
+
+ const { getRuleFormsErrors } = useRuleFormsErrors();
+
const esqlQueryForAboutStep = useEsqlQueryForAboutStep({ defineStepData, activeStep });
const esqlIndex = useEsqlIndex(defineStepData.queryBar.query.query, ruleType);
@@ -328,89 +335,173 @@ const CreateRulePageComponent: React.FC = () => {
const validateStep = useCallback(
async (step: RuleStep) => {
switch (step) {
- case RuleStep.defineRule:
- return defineStepForm.validate();
- case RuleStep.aboutRule:
- return aboutStepForm.validate();
- case RuleStep.scheduleRule:
- return scheduleStepForm.validate();
- case RuleStep.ruleActions:
- return actionsStepForm.validate();
+ case RuleStep.defineRule: {
+ const valid = await defineStepForm.validate();
+ const { blockingErrors, nonBlockingErrors } = getRuleFormsErrors({ defineStepForm });
+ return { valid, blockingErrors, nonBlockingErrors };
+ }
+ case RuleStep.aboutRule: {
+ const valid = await aboutStepForm.validate();
+ const { blockingErrors, nonBlockingErrors } = getRuleFormsErrors({ aboutStepForm });
+ return { valid, blockingErrors, nonBlockingErrors };
+ }
+ case RuleStep.scheduleRule: {
+ const valid = await scheduleStepForm.validate();
+ const { blockingErrors, nonBlockingErrors } = getRuleFormsErrors({ scheduleStepForm });
+ return { valid, blockingErrors, nonBlockingErrors };
+ }
+ case RuleStep.ruleActions: {
+ const valid = await actionsStepForm.validate();
+ const { blockingErrors, nonBlockingErrors } = getRuleFormsErrors({ actionsStepForm });
+ return { valid, blockingErrors, nonBlockingErrors };
+ }
}
},
- [aboutStepForm, actionsStepForm, defineStepForm, scheduleStepForm]
+ [aboutStepForm, actionsStepForm, defineStepForm, getRuleFormsErrors, scheduleStepForm]
+ );
+
+ const validateEachStep = useCallback(async () => {
+ const {
+ valid: defineStepFormValid,
+ blockingErrors: defineStepBlockingErrors,
+ nonBlockingErrors: defineStepNonBlockingErrors,
+ } = await validateStep(RuleStep.defineRule);
+ const {
+ valid: aboutStepFormValid,
+ blockingErrors: aboutStepBlockingErrors,
+ nonBlockingErrors: aboutStepNonBlockingErrors,
+ } = await validateStep(RuleStep.aboutRule);
+ const {
+ valid: scheduleStepFormValid,
+ blockingErrors: scheduleStepBlockingErrors,
+ nonBlockingErrors: scheduleStepNonBlockingErrors,
+ } = await validateStep(RuleStep.scheduleRule);
+ const {
+ valid: actionsStepFormValid,
+ blockingErrors: actionsStepBlockingErrors,
+ nonBlockingErrors: actionsStepNonBlockingErrors,
+ } = await validateStep(RuleStep.ruleActions);
+ const valid =
+ defineStepFormValid && aboutStepFormValid && scheduleStepFormValid && actionsStepFormValid;
+
+ const blockingErrors = [
+ ...defineStepBlockingErrors,
+ ...aboutStepBlockingErrors,
+ ...scheduleStepBlockingErrors,
+ ...actionsStepBlockingErrors,
+ ];
+ const nonBlockingErrors = [
+ ...defineStepNonBlockingErrors,
+ ...aboutStepNonBlockingErrors,
+ ...scheduleStepNonBlockingErrors,
+ ...actionsStepNonBlockingErrors,
+ ];
+
+ return { valid, blockingErrors, nonBlockingErrors };
+ }, [validateStep]);
+
+ const isValidStep = useCallback(
+ async (step: RuleStep) => {
+ const { valid, blockingErrors } = await validateStep(step);
+ return valid || !blockingErrors.length;
+ },
+ [validateStep]
);
const editStep = useCallback(
async (step: RuleStep) => {
- const valid = await validateStep(activeStep);
-
+ const valid = await isValidStep(activeStep);
if (valid) {
goToStep(step);
}
},
- [activeStep, validateStep, goToStep]
+ [isValidStep, activeStep, goToStep]
);
- const submitRule = useCallback(
- async (step: RuleStep, enabled: boolean) => {
- const valid = await validateStep(step);
-
- if (valid) {
- const localDefineStepData: DefineStepRule = defineFieldsTransform({
- ...defineStepForm.getFormData(),
- eqlOptions: eqlOptionsSelected,
- });
- const localAboutStepData = aboutStepForm.getFormData();
- const localScheduleStepData = scheduleStepForm.getFormData();
- const localActionsStepData = actionsStepForm.getFormData();
- const startMlJobsIfNeeded = async () => {
- if (!isMlRule(ruleType) || !enabled) {
- return;
- }
- await startMlJobs(localDefineStepData.machineLearningJobId);
- };
- const [, createdRule] = await Promise.all([
- startMlJobsIfNeeded(),
- createRule(
- formatRule(
- localDefineStepData,
- localAboutStepData,
- localScheduleStepData,
- {
- ...localActionsStepData,
- enabled,
- },
- triggersActionsUi.actionTypeRegistry
- )
- ),
- ]);
-
- addSuccess(i18n.SUCCESSFULLY_CREATED_RULES(createdRule.name));
-
- navigateToApp(APP_UI_ID, {
- deepLinkId: SecurityPageName.rules,
- path: getRuleDetailsUrl(createdRule.id),
- });
- }
+ const createRuleFromFormData = useCallback(
+ async (enabled: boolean) => {
+ const localDefineStepData: DefineStepRule = defineFieldsTransform({
+ ...defineStepForm.getFormData(),
+ eqlOptions: eqlOptionsSelected,
+ });
+ const localAboutStepData = aboutStepForm.getFormData();
+ const localScheduleStepData = scheduleStepForm.getFormData();
+ const localActionsStepData = actionsStepForm.getFormData();
+ const startMlJobsIfNeeded = async () => {
+ if (!isMlRule(ruleType) || !enabled) {
+ return;
+ }
+ await startMlJobs(localDefineStepData.machineLearningJobId);
+ };
+ const [, createdRule] = await Promise.all([
+ startMlJobsIfNeeded(),
+ createRule(
+ formatRule(
+ localDefineStepData,
+ localAboutStepData,
+ localScheduleStepData,
+ {
+ ...localActionsStepData,
+ enabled,
+ },
+ triggersActionsUi.actionTypeRegistry
+ )
+ ),
+ ]);
+
+ addSuccess(i18n.SUCCESSFULLY_CREATED_RULES(createdRule.name));
+
+ navigateToApp(APP_UI_ID, {
+ deepLinkId: SecurityPageName.rules,
+ path: getRuleDetailsUrl(createdRule.id),
+ });
},
[
- validateStep,
- defineStepForm,
- eqlOptionsSelected,
aboutStepForm,
- scheduleStepForm,
actionsStepForm,
- createRule,
addSuccess,
+ createRule,
+ defineFieldsTransform,
+ defineStepForm,
+ eqlOptionsSelected,
navigateToApp,
ruleType,
+ scheduleStepForm,
startMlJobs,
- defineFieldsTransform,
triggersActionsUi.actionTypeRegistry,
]
);
+ const submitRule = useCallback(
+ async (enabled: boolean) => {
+ const { valid, blockingErrors, nonBlockingErrors } = await validateEachStep();
+ if (valid) {
+ // There are no validation errors, thus proceed to rule creation
+ createRuleFromFormData(enabled);
+ return;
+ }
+
+ if (blockingErrors.length > 0) {
+ // There are blocking validation errors, thus do not allow user to create a rule
+ return;
+ }
+ if (nonBlockingErrors.length > 0) {
+ // There are non-blocking validation errors, thus confirm that user understand that this can cause rule failures
+ setEnableRuleAfterConfirmation(enabled);
+ setNonBlockingRuleErrors(nonBlockingErrors);
+ showSaveWithErrorsModal();
+ }
+ },
+ [createRuleFromFormData, validateEachStep]
+ );
+
+ const showSaveWithErrorsModal = () => setIsSaveWithErrorsModalVisible(true);
+ const closeSaveWithErrorsModal = () => setIsSaveWithErrorsModalVisible(false);
+ const onConfirmSaveWithErrors = useCallback(async () => {
+ closeSaveWithErrorsModal();
+ await createRuleFromFormData(enableRuleAfterConfirmation);
+ }, [createRuleFromFormData, enableRuleAfterConfirmation]);
+
const defineRuleButtonType =
activeStep === RuleStep.defineRule ? 'active' : defineStepForm.isValid ? 'valid' : 'passive';
const defineRuleButton = useMemo(
@@ -418,14 +509,14 @@ const CreateRulePageComponent: React.FC = () => {
[defineRuleButtonType]
);
const defineRuleNextStep = useCallback(async () => {
- const valid = await defineStepForm.validate();
+ const valid = await isValidStep(RuleStep.defineRule);
if (valid) {
const nextStep = getNextStep(RuleStep.defineRule);
if (nextStep) {
goToStep(nextStep);
}
}
- }, [defineStepForm, goToStep]);
+ }, [goToStep, isValidStep]);
const aboutRuleButtonType =
activeStep === RuleStep.aboutRule ? 'active' : aboutStepForm.isValid ? 'valid' : 'passive';
@@ -434,14 +525,14 @@ const CreateRulePageComponent: React.FC = () => {
[aboutRuleButtonType]
);
const aboutRuleNextStep = useCallback(async () => {
- const valid = await aboutStepForm.validate();
+ const valid = await isValidStep(RuleStep.aboutRule);
if (valid) {
const nextStep = getNextStep(RuleStep.aboutRule);
if (nextStep) {
goToStep(nextStep);
}
}
- }, [aboutStepForm, goToStep]);
+ }, [goToStep, isValidStep]);
const scheduleRuleButtonType =
activeStep === RuleStep.scheduleRule
@@ -454,14 +545,14 @@ const CreateRulePageComponent: React.FC = () => {
[scheduleRuleButtonType]
);
const scheduleRuleNextStep = useCallback(async () => {
- const valid = await scheduleStepForm.validate();
+ const valid = await isValidStep(RuleStep.scheduleRule);
if (valid) {
const nextStep = getNextStep(RuleStep.scheduleRule);
if (nextStep) {
goToStep(nextStep);
}
}
- }, [scheduleStepForm, goToStep]);
+ }, [isValidStep, goToStep]);
const actionsRuleButtonType =
activeStep === RuleStep.ruleActions ? 'active' : actionsStepForm.isValid ? 'valid' : 'passive';
@@ -470,10 +561,10 @@ const CreateRulePageComponent: React.FC = () => {
[actionsRuleButtonType]
);
const submitRuleDisabled = useCallback(() => {
- submitRule(RuleStep.ruleActions, false);
+ submitRule(false);
}, [submitRule]);
const submitRuleEnabled = useCallback(() => {
- submitRule(RuleStep.ruleActions, true);
+ submitRule(true);
}, [submitRule]);
const memoDefineStepReadOnly = useMemo(
@@ -559,7 +650,7 @@ const CreateRulePageComponent: React.FC = () => {
);
const memoDefineStepExtraAction = useMemo(
() =>
- defineStepForm.isValid && (
+ activeStep !== RuleStep.defineRule && (
{
{i18n.EDIT_RULE}
),
- [defineStepForm.isValid, editStep]
+ [activeStep, editStep]
);
const memoAboutStepReadOnly = useMemo(
@@ -629,7 +720,7 @@ const CreateRulePageComponent: React.FC = () => {
);
const memoAboutStepExtraAction = useMemo(
() =>
- aboutStepForm.isValid && (
+ activeStep !== RuleStep.aboutRule && (
{
{i18n.EDIT_RULE}
),
- [aboutStepForm.isValid, editStep]
+ [activeStep, editStep]
);
const memoStepScheduleRule = useMemo(
@@ -682,12 +773,12 @@ const CreateRulePageComponent: React.FC = () => {
);
const memoScheduleStepExtraAction = useMemo(
() =>
- scheduleStepForm.isValid && (
+ activeStep !== RuleStep.scheduleRule && (
editStep(RuleStep.scheduleRule)}>
{i18n.EDIT_RULE}
),
- [editStep, scheduleStepForm.isValid]
+ [activeStep, editStep]
);
const memoStepRuleActions = useMemo(
@@ -762,12 +853,12 @@ const CreateRulePageComponent: React.FC = () => {
);
const memoActionsStepExtraAction = useMemo(
() =>
- actionsStepForm.isValid && (
+ activeStep !== RuleStep.ruleActions && (
editStep(RuleStep.ruleActions)}>
{i18n.EDIT_RULE}
),
- [actionsStepForm.isValid, editStep]
+ [activeStep, editStep]
);
const onToggleCollapsedMemo = useCallback(
@@ -798,6 +889,13 @@ const CreateRulePageComponent: React.FC = () => {
return (
<>
+ {isSaveWithErrorsModalVisible && (
+
+ )}
{(EuiResizablePanel, EuiResizableButton, { togglePanel }) => {
diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_editing/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_editing/index.tsx
index 5e6ed40d4d28..0544f01bb1b7 100644
--- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_editing/index.tsx
+++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_editing/index.tsx
@@ -67,9 +67,10 @@ import {
import { useStartTransaction } from '../../../../common/lib/apm/use_start_transaction';
import { SINGLE_RULE_ACTIONS } from '../../../../common/lib/apm/user_actions';
import { useGetSavedQuery } from '../../../../detections/pages/detection_engine/rules/use_get_saved_query';
-import { useRuleForms, useRuleIndexPattern } from '../form';
+import { useRuleForms, useRuleFormsErrors, useRuleIndexPattern } from '../form';
import { useEsqlIndex, useEsqlQueryForAboutStep } from '../../hooks';
import { CustomHeaderPageMemo } from '..';
+import { SaveWithErrorsModal } from '../../components/save_with_errors_confirmation';
const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => {
const [, dispatchToaster] = useStateToaster();
@@ -99,6 +100,9 @@ const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => {
const [isQueryBarValid, setIsQueryBarValid] = useState(false);
const [isThreatQueryBarValid, setIsThreatQueryBarValid] = useState(false);
+ const [isSaveWithErrorsModalVisible, setIsSaveWithErrorsModalVisible] = useState(false);
+ const [nonBlockingRuleErrors, setNonBlockingRuleErrors] = useState([]);
+
useEffect(() => {
const fetchDataViews = async () => {
const dataViewsRefs = await dataServices.dataViews.getIdsWithTitle();
@@ -149,6 +153,8 @@ const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => {
actionsStepDefault: ruleActionsData,
});
+ const { getRuleFormsErrors } = useRuleFormsErrors();
+
const esqlQueryForAboutStep = useEsqlQueryForAboutStep({ defineStepData, activeStep });
const esqlIndex = useEsqlIndex(defineStepData.queryBar.query.query, defineStepData.ruleType);
@@ -386,7 +392,43 @@ const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => {
const { startTransaction } = useStartTransaction();
+ const saveChanges = useCallback(async () => {
+ startTransaction({ name: SINGLE_RULE_ACTIONS.SAVE });
+ await updateRule({
+ ...formatRule(
+ defineStepData,
+ aboutStepData,
+ scheduleStepData,
+ actionsStepData,
+ triggersActionsUi.actionTypeRegistry,
+ rule?.exceptions_list
+ ),
+ ...(ruleId ? { id: ruleId } : {}),
+ });
+
+ displaySuccessToast(i18n.SUCCESSFULLY_SAVED_RULE(rule?.name ?? ''), dispatchToaster);
+ navigateToApp(APP_UI_ID, {
+ deepLinkId: SecurityPageName.rules,
+ path: getRuleDetailsUrl(ruleId ?? ''),
+ });
+ }, [
+ aboutStepData,
+ actionsStepData,
+ defineStepData,
+ dispatchToaster,
+ navigateToApp,
+ rule?.exceptions_list,
+ rule?.name,
+ ruleId,
+ scheduleStepData,
+ startTransaction,
+ triggersActionsUi.actionTypeRegistry,
+ updateRule,
+ ]);
+
const onSubmit = useCallback(async () => {
+ setNonBlockingRuleErrors([]);
+
const defineStepFormValid = await defineStepForm.validate();
const aboutStepFormValid = await aboutStepForm.validate();
const scheduleStepFormValid = await scheduleStepForm.validate();
@@ -398,43 +440,39 @@ const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => {
scheduleStepFormValid &&
actionsStepFormValid
) {
- startTransaction({ name: SINGLE_RULE_ACTIONS.SAVE });
- await updateRule({
- ...formatRule(
- defineStepData,
- aboutStepData,
- scheduleStepData,
- actionsStepData,
- triggersActionsUi.actionTypeRegistry,
- rule?.exceptions_list
- ),
- ...(ruleId ? { id: ruleId } : {}),
- });
+ await saveChanges();
+ return;
+ }
- displaySuccessToast(i18n.SUCCESSFULLY_SAVED_RULE(rule?.name ?? ''), dispatchToaster);
- navigateToApp(APP_UI_ID, {
- deepLinkId: SecurityPageName.rules,
- path: getRuleDetailsUrl(ruleId ?? ''),
- });
+ const { blockingErrors, nonBlockingErrors } = getRuleFormsErrors({
+ defineStepForm,
+ aboutStepForm,
+ scheduleStepForm,
+ actionsStepForm,
+ });
+ if (blockingErrors.length > 0) {
+ return;
+ }
+ if (nonBlockingErrors.length > 0) {
+ setNonBlockingRuleErrors(nonBlockingErrors);
+ showSaveWithErrorsModal();
}
}, [
defineStepForm,
aboutStepForm,
scheduleStepForm,
actionsStepForm,
- startTransaction,
- updateRule,
- defineStepData,
- aboutStepData,
- scheduleStepData,
- actionsStepData,
- rule,
- ruleId,
- dispatchToaster,
- navigateToApp,
- triggersActionsUi.actionTypeRegistry,
+ getRuleFormsErrors,
+ saveChanges,
]);
+ const showSaveWithErrorsModal = () => setIsSaveWithErrorsModalVisible(true);
+ const closeSaveWithErrorsModal = () => setIsSaveWithErrorsModalVisible(false);
+ const onConfirmSaveWithErrors = useCallback(async () => {
+ closeSaveWithErrorsModal();
+ await saveChanges();
+ }, [saveChanges]);
+
const onTabClick = useCallback(async (tab: EuiTabbedContentTab) => {
const targetStep = tab.id as RuleStep;
setActiveStep(targetStep);
@@ -488,6 +526,13 @@ const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => {
return (
<>
+ {isSaveWithErrorsModalVisible && (
+
+ )}
{(EuiResizablePanel, EuiResizableButton, { togglePanel }) => {
diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/translations.ts
index e602b8be712c..7a2064dd4f35 100644
--- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/translations.ts
+++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/translations.ts
@@ -13,3 +13,9 @@ export const RULE_PREVIEW_TITLE = i18n.translate(
defaultMessage: 'Rule preview',
}
);
+
+export const QUERY_BAR_VALIDATION_ERROR = (vlidationError: string) =>
+ i18n.translate('xpack.securitySolution.detectionEngine.createRule.validationError', {
+ values: { vlidationError },
+ defaultMessage: 'Query bar: {vlidationError}',
+ });
diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/eql_rule.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/eql_rule.cy.ts
index 2b0b9f3fdab6..a14218fcdda5 100644
--- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/eql_rule.cy.ts
+++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/eql_rule.cy.ts
@@ -46,10 +46,13 @@ import { getDetails } from '../../../../tasks/rule_details';
import { expectNumberOfRules, goToRuleDetailsOf } from '../../../../tasks/alerts_detection_rules';
import { deleteAlertsAndRules } from '../../../../tasks/api_calls/common';
import {
+ continueFromDefineStep,
createAndEnableRule,
+ createRuleWithNonBlockingErrors,
fillAboutRuleAndContinue,
fillDefineEqlRuleAndContinue,
fillScheduleRuleAndContinue,
+ getDefineContinueButton,
getIndexPatternClearButton,
getRuleIndexInput,
selectEqlRuleType,
@@ -225,6 +228,8 @@ describe('EQL rules', { tags: ['@ess', '@serverless'] }, () => {
});
describe('EQL query validation', () => {
+ const rule = getEqlRule();
+
it('validates missing data source', () => {
login();
visit(CREATE_RULE_URL);
@@ -236,14 +241,20 @@ describe('EQL rules', { tags: ['@ess', '@serverless'] }, () => {
cy.get(RULES_CREATION_FORM).find(EQL_QUERY_INPUT).should('be.visible');
cy.get(RULES_CREATION_FORM).find(EQL_QUERY_INPUT).type('any where true');
+ const expectedValidationError = `index_not_found_exception\n\tCaused by:\n\t\tverification_exception: Found 1 problem\nline -1:-1: Unknown index [*,-*]\n\tRoot causes:\n\t\tverification_exception: Found 1 problem\nline -1:-1: Unknown index [*,-*]`;
cy.get(EQL_QUERY_VALIDATION_ERROR).should('be.visible');
cy.get(EQL_QUERY_VALIDATION_ERROR).should('have.text', '1');
cy.get(EQL_QUERY_VALIDATION_ERROR).click();
cy.get(EQL_QUERY_VALIDATION_ERROR_CONTENT).should('be.visible');
cy.get(EQL_QUERY_VALIDATION_ERROR_CONTENT).should(
'have.text',
- `EQL Validation Errorsindex_not_found_exception\n\tCaused by:\n\t\tverification_exception: Found 1 problem\nline -1:-1: Unknown index [*,-*]\n\tRoot causes:\n\t\tverification_exception: Found 1 problem\nline -1:-1: Unknown index [*,-*]`
+ `EQL Validation Errors${expectedValidationError}`
);
+ continueFromDefineStep();
+
+ fillAboutRuleAndContinue(rule);
+ fillScheduleRuleAndContinue(rule);
+ createRuleWithNonBlockingErrors();
});
it('validates missing data fields', () => {
@@ -263,6 +274,11 @@ describe('EQL rules', { tags: ['@ess', '@serverless'] }, () => {
'have.text',
'EQL Validation ErrorsFound 1 problem\nline 1:11: Unknown column [field1]'
);
+ continueFromDefineStep();
+
+ fillAboutRuleAndContinue(rule);
+ fillScheduleRuleAndContinue(rule);
+ createRuleWithNonBlockingErrors();
});
it('validates syntax errors', () => {
@@ -282,6 +298,8 @@ describe('EQL rules', { tags: ['@ess', '@serverless'] }, () => {
'have.text',
`EQL Validation Errorsline 1:6: extraneous input 'any' expecting 'where'`
);
+ continueFromDefineStep();
+ getDefineContinueButton().should('exist');
});
});
});
diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/esql_rule.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/esql_rule.cy.ts
index 348133b1d280..3727c2ccbd50 100644
--- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/esql_rule.cy.ts
+++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/esql_rule.cy.ts
@@ -49,6 +49,7 @@ import {
fillAboutRuleMinimumAndContinue,
skipScheduleRuleAction,
interceptEsqlQueryFieldsRequest,
+ createRuleWithNonBlockingErrors,
} from '../../../../tasks/create_new_rule';
import { login } from '../../../../tasks/login';
import { visit } from '../../../../tasks/navigation';
@@ -191,6 +192,21 @@ describe(
`Error validating ES|QL: "SyntaxError: extraneous input 'test' expecting "`
);
});
+
+ it('shows confirmation modal about existing non-blocking validation errors', function () {
+ const nonExistingDataSourceQuery = 'from fake* metadata _id, _version, _index | limit 5';
+ selectEsqlRuleType();
+ fillEsqlQueryBar(nonExistingDataSourceQuery);
+ getDefineContinueButton().click();
+
+ fillRuleName();
+ fillDescription();
+ getAboutContinueButton().click();
+
+ fillScheduleRuleAndContinue(rule);
+
+ createRuleWithNonBlockingErrors();
+ });
});
describe('ES|QL investigation fields', () => {
diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/eql_query_rule.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/eql_query_rule.cy.ts
new file mode 100644
index 000000000000..994dbb2eb8ce
--- /dev/null
+++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/eql_query_rule.cy.ts
@@ -0,0 +1,47 @@
+/*
+ * 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 { getEqlRule } from '../../../../objects/rule';
+
+import { createRule } from '../../../../tasks/api_calls/rules';
+import { deleteAlertsAndRules } from '../../../../tasks/api_calls/common';
+import {
+ saveEditedRuleWithNonBlockingErrors,
+ visitEditRulePage,
+} from '../../../../tasks/edit_rule';
+import { login } from '../../../../tasks/login';
+
+describe('EQL query rules', { tags: ['@ess', '@serverless'] }, () => {
+ context('Editing rule with non-blocking query validation errors', () => {
+ beforeEach(() => {
+ login();
+ deleteAlertsAndRules();
+ });
+
+ it('should allow user to save a rule and show confirmation modal when data source does not exist', () => {
+ const rule = {
+ ...getEqlRule(),
+ index: ['fake*'],
+ };
+ createRule(rule).then((createdRule) => {
+ visitEditRulePage(createdRule.body.id);
+ saveEditedRuleWithNonBlockingErrors();
+ });
+ });
+
+ it('should allow user to save a rule and show confirmation modal when data field does not exist', () => {
+ const rule = {
+ ...getEqlRule(),
+ query: 'any where hello.world',
+ };
+ createRule(rule).then((createdRule) => {
+ visitEditRulePage(createdRule.body.id);
+ saveEditedRuleWithNonBlockingErrors();
+ });
+ });
+ });
+});
diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/esql_rule.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/esql_rule.cy.ts
index 43655b1358b2..a180e1a0b50c 100644
--- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/esql_rule.cy.ts
+++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/esql_rule.cy.ts
@@ -43,7 +43,11 @@ import { login } from '../../../../tasks/login';
import { editFirstRule } from '../../../../tasks/alerts_detection_rules';
-import { saveEditedRule } from '../../../../tasks/edit_rule';
+import {
+ saveEditedRule,
+ saveEditedRuleWithNonBlockingErrors,
+ visitEditRulePage,
+} from '../../../../tasks/edit_rule';
import { visit } from '../../../../tasks/navigation';
const rule = getEsqlRule();
@@ -192,5 +196,29 @@ describe(
});
});
});
+
+ describe('Editing rule with non-blocking query validation errors', () => {
+ it('should allow user to save a rule and show confirmation modal when data source does not exist', () => {
+ const esqlRule = {
+ ...rule,
+ query: 'from fake-* metadata _id, _version, _index | keep agent.*,_id | eval test_id=_id',
+ };
+ createRule(esqlRule).then((createdRule) => {
+ visitEditRulePage(createdRule.body.id);
+ saveEditedRuleWithNonBlockingErrors();
+ });
+ });
+
+ it('should allow user to save a rule and show confirmation modal when data field does not exist', () => {
+ const esqlRule = {
+ ...rule,
+ query: 'from auditbeat-* metadata _id, _version, _index | keep hello.world',
+ };
+ createRule(esqlRule).then((createdRule) => {
+ visitEditRulePage(createdRule.body.id);
+ saveEditedRuleWithNonBlockingErrors();
+ });
+ });
+ });
}
);
diff --git a/x-pack/test/security_solution_cypress/cypress/screens/create_new_rule.ts b/x-pack/test/security_solution_cypress/cypress/screens/create_new_rule.ts
index 4e6df1ed3a75..72d1104985d7 100644
--- a/x-pack/test/security_solution_cypress/cypress/screens/create_new_rule.ts
+++ b/x-pack/test/security_solution_cypress/cypress/screens/create_new_rule.ts
@@ -56,6 +56,10 @@ export const CREATE_AND_ENABLE_BTN = '[data-test-subj="create-enable"]';
export const CREATE_WITHOUT_ENABLING_BTN = '[data-test-subj="create-enabled-false"]';
+export const SAVE_WITH_ERRORS_MODAL = '[data-test-subj="save-with-errors-confirmation-modal"]';
+
+export const SAVE_WITH_ERRORS_MODAL_CONFIRM_BTN = '[data-test-subj="confirmModalConfirmButton"]';
+
export const CUSTOM_QUERY_INPUT = '[data-test-subj="queryInput"]';
export const CUSTOM_QUERY_BAR = '[data-test-subj="detectionEngineStepDefineRuleQueryBar"]';
diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/create_new_rule.ts b/x-pack/test/security_solution_cypress/cypress/tasks/create_new_rule.ts
index 4251ef8ee0ec..045c40da4b2c 100644
--- a/x-pack/test/security_solution_cypress/cypress/tasks/create_new_rule.ts
+++ b/x-pack/test/security_solution_cypress/cypress/tasks/create_new_rule.ts
@@ -128,6 +128,8 @@ import {
MAX_SIGNALS_INPUT,
SETUP_GUIDE_TEXTAREA,
RELATED_INTEGRATION_COMBO_BOX_INPUT,
+ SAVE_WITH_ERRORS_MODAL,
+ SAVE_WITH_ERRORS_MODAL_CONFIRM_BTN,
} from '../screens/create_new_rule';
import {
INDEX_SELECTOR,
@@ -162,6 +164,14 @@ export const createRuleWithoutEnabling = () => {
cy.get(CREATE_WITHOUT_ENABLING_BTN).should('not.exist');
};
+export const createRuleWithNonBlockingErrors = () => {
+ cy.get(CREATE_AND_ENABLE_BTN).click();
+ cy.get(SAVE_WITH_ERRORS_MODAL).should('exist');
+ cy.get(SAVE_WITH_ERRORS_MODAL_CONFIRM_BTN).first().click();
+ cy.get(SAVE_WITH_ERRORS_MODAL).should('not.exist');
+ cy.get(CREATE_AND_ENABLE_BTN).should('not.exist');
+};
+
export const fillAboutRule = (rule: RuleCreateProps) => {
cy.get(RULE_NAME_INPUT).clear({ force: true });
cy.get(RULE_NAME_INPUT).type(rule.name, { force: true });
diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/edit_rule.ts b/x-pack/test/security_solution_cypress/cypress/tasks/edit_rule.ts
index 14c9ef05aa87..4ea919231e08 100644
--- a/x-pack/test/security_solution_cypress/cypress/tasks/edit_rule.ts
+++ b/x-pack/test/security_solution_cypress/cypress/tasks/edit_rule.ts
@@ -5,6 +5,10 @@
* 2.0.
*/
+import {
+ SAVE_WITH_ERRORS_MODAL,
+ SAVE_WITH_ERRORS_MODAL_CONFIRM_BTN,
+} from '../screens/create_new_rule';
import { BACK_TO_RULE_DETAILS, EDIT_SUBMIT_BUTTON } from '../screens/edit_rule';
import { editRuleUrl } from '../urls/edit_rule';
import { visit } from './navigation';
@@ -18,6 +22,14 @@ export const saveEditedRule = () => {
cy.get(EDIT_SUBMIT_BUTTON).should('not.exist');
};
+export const saveEditedRuleWithNonBlockingErrors = () => {
+ cy.get(EDIT_SUBMIT_BUTTON).click();
+ cy.get(SAVE_WITH_ERRORS_MODAL).should('exist');
+ cy.get(SAVE_WITH_ERRORS_MODAL_CONFIRM_BTN).first().click();
+ cy.get(SAVE_WITH_ERRORS_MODAL).should('not.exist');
+ cy.get(EDIT_SUBMIT_BUTTON).should('not.exist');
+};
+
export const goBackToRuleDetails = () => {
cy.get(BACK_TO_RULE_DETAILS).should('exist').click();
cy.get(BACK_TO_RULE_DETAILS).should('not.exist');