[Security Solution] - Security solution ES|QL configurable via advanced setting (#181616)

## Summary

This PR links the ESQL functionality in security solution to the
`discover:enableESQL` advanced setting. The advanced setting will only
be present in ESS, but not serverless

The way this should work to maintain parity with the rest of Kibana such
as discover and stack rules:

- By default ES|QL will be enabled across all Kibana
- When the ES|QL advanced setting is disabled:
  - Timeline
    - ES|QL tab should not be accessible on any newly created timelines
- Existing Timelines with an ES|QL query should still have the tab
accessible
  - Rules
- New ES|QL rule should not be available to be created in the *Rule
Creation* workflow
    - Existing ES|QL rules should still run and be able to be edited



**Timeline Demo Video:**


d5429be9-de37-43e2-882d-687b3371beb4

**Rules Demo Video:**



7df2fd11-bd2b-4e50-ad97-b6e1d0f7867a

---------

Co-authored-by: Vitalii Dmyterko <92328789+vitaliidm@users.noreply.github.com>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Michael Olorunnisola 2024-05-02 13:02:46 -04:00 committed by GitHub
parent 187f22a2b0
commit 963391ed0f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 168 additions and 56 deletions

View file

@ -23,7 +23,6 @@ xpack.securitySolutionServerless.productTypes:
xpack.securitySolution.offeringSettings: {
ILMEnabled: false, # Index Lifecycle Management (ILM) functionalities disabled, not supported by serverless Elasticsearch
ESQLEnabled: false, # ES|QL disabled, not supported by serverless Elasticsearch
}
newsfeed.enabled: true

View file

@ -10,10 +10,6 @@ export interface ConfigSettings {
* Index Lifecycle Management (ILM) feature enabled.
*/
ILMEnabled: boolean;
/**
* ESQL queries enabled.
*/
ESQLEnabled: boolean;
}
/**
@ -22,7 +18,6 @@ export interface ConfigSettings {
*/
export const defaultSettings: ConfigSettings = Object.freeze({
ILMEnabled: true,
ESQLEnabled: true,
});
type ConfigSettingsKey = keyof ConfigSettings;

View file

@ -1,8 +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.
*/
export { useIsEsqlRuleTypeEnabled } from './use_is_esql_rule_type_enabled';

View file

@ -1,16 +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 { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features';
import { useKibana } from '../../lib/kibana';
export const useIsEsqlRuleTypeEnabled = (): boolean => {
const isEsqlSettingEnabled = useKibana().services.configSettings.ESQLEnabled;
const isEsqlRuleTypeEnabled = !useIsExperimentalFeatureEnabled('esqlRulesDisabled');
return isEsqlSettingEnabled && isEsqlRuleTypeEnabled;
};

View file

@ -0,0 +1,28 @@
/*
* 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 { useKibana } from '../../lib/kibana';
import { useIsExperimentalFeatureEnabled } from '../use_experimental_features';
export const useEsqlAvailability = () => {
const { uiSettings } = useKibana().services;
const isEsqlAdvancedSettingEnabled = uiSettings?.get('discover:enableESQL');
const isEsqlRuleTypeEnabled =
!useIsExperimentalFeatureEnabled('esqlRulesDisabled') && isEsqlAdvancedSettingEnabled;
const isESQLTabInTimelineEnabled =
!useIsExperimentalFeatureEnabled('timelineEsqlTabDisabled') && isEsqlAdvancedSettingEnabled;
return useMemo(
() => ({
isEsqlAdvancedSettingEnabled,
isEsqlRuleTypeEnabled,
isESQLTabInTimelineEnabled,
}),
[isESQLTabInTimelineEnabled, isEsqlAdvancedSettingEnabled, isEsqlRuleTypeEnabled]
);
};

View file

@ -10,12 +10,12 @@ import { mount, shallow } from 'enzyme';
import { SelectRuleType } from '.';
import { TestProviders, useFormFieldMock } from '../../../../common/mock';
import { useIsEsqlRuleTypeEnabled } from '../../../../common/components/hooks';
import { useEsqlAvailability } from '../../../../common/hooks/esql/use_esql_availability';
jest.mock('../../../../common/components/hooks', () => ({
useIsEsqlRuleTypeEnabled: jest.fn().mockReturnValue(true),
jest.mock('../../../../common/hooks/esql/use_esql_availability', () => ({
useEsqlAvailability: jest.fn().mockReturnValue({ isEsqlRuleTypeEnabled: true }),
}));
const useIsEsqlRuleTypeEnabledMock = useIsEsqlRuleTypeEnabled as jest.Mock;
const useEsqlAvailabilityMock = useEsqlAvailability as jest.Mock;
describe('SelectRuleType', () => {
it('renders correctly', () => {
@ -185,8 +185,30 @@ describe('SelectRuleType', () => {
expect(wrapper.find('[data-test-subj="esqlRuleType"]').exists()).toBeTruthy();
});
it('renders selected card only when in update mode for "esql" and esql feature is disabled', () => {
useEsqlAvailabilityMock.mockReturnValueOnce(false);
const field = useFormFieldMock<unknown>({ value: 'esql' });
const wrapper = mount(
<TestProviders>
<SelectRuleType
describedByIds={[]}
field={field}
isUpdateView={true}
hasValidLicense={true}
isMlAdmin={true}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="customRuleType"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="machineLearningRuleType"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="thresholdRuleType"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="eqlRuleType"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="threatMatchRuleType"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="esqlRuleType"]').exists()).toBeTruthy();
});
it('should not render "esql" rule type if esql rule is not enabled', () => {
useIsEsqlRuleTypeEnabledMock.mockReturnValueOnce(false);
useEsqlAvailabilityMock.mockReturnValueOnce(false);
const Component = () => {
const field = useFormFieldMock();

View file

@ -9,6 +9,7 @@ import React, { useCallback, useMemo, memo } from 'react';
import { EuiCard, EuiFlexGrid, EuiFlexItem, EuiFormRow, EuiIcon } from '@elastic/eui';
import type { Type } from '@kbn/securitysolution-io-ts-alerting-types';
import { useEsqlAvailability } from '../../../../common/hooks/esql/use_esql_availability';
import { isMlRule } from '../../../../../common/machine_learning/helpers';
import {
isThresholdRule,
@ -21,7 +22,6 @@ import {
import type { FieldHook } from '../../../../shared_imports';
import * as i18n from './translations';
import { MlCardDescription } from './ml_card_description';
import { useIsEsqlRuleTypeEnabled } from '../../../../common/components/hooks';
interface SelectRuleTypeProps {
describedByIds: string[];
@ -48,7 +48,7 @@ export const SelectRuleType: React.FC<SelectRuleTypeProps> = memo(
const setNewTerms = useCallback(() => setType('new_terms'), [setType]);
const setEsql = useCallback(() => setType('esql'), [setType]);
const isEsqlRuleTypeEnabled = useIsEsqlRuleTypeEnabled();
const { isEsqlRuleTypeEnabled } = useEsqlAvailability();
const eqlSelectableConfig = useMemo(
() => ({
@ -194,7 +194,7 @@ export const SelectRuleType: React.FC<SelectRuleTypeProps> = memo(
/>
</EuiFlexItem>
)}
{isEsqlRuleTypeEnabled && (!isUpdateView || esqlSelectableConfig.isSelected) && (
{((!isUpdateView && isEsqlRuleTypeEnabled) || esqlSelectableConfig.isSelected) && (
<EuiFlexItem>
<EuiCard
data-test-subj="esqlRuleType"

View file

@ -0,0 +1,82 @@
/*
* 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 { createMockStore, mockGlobalState } from '../../../../common/mock';
import { TestProviders } from '../../../../common/mock/test_providers';
import { TabsContent } from '.';
import { TimelineId, TimelineTabs } from '../../../../../common/types/timeline';
import { TimelineType } from '../../../../../common/api/timeline';
import { useEsqlAvailability } from '../../../../common/hooks/esql/use_esql_availability';
import { render, screen, waitFor } from '@testing-library/react';
jest.mock('../../../../common/hooks/esql/use_esql_availability', () => ({
useEsqlAvailability: jest.fn().mockReturnValue({
isESQLTabInTimelineEnabled: true,
}),
}));
const useEsqlAvailabilityMock = useEsqlAvailability as jest.Mock;
describe('Timeline', () => {
describe('esql tab', () => {
const esqlTabSubj = `timelineTabs-${TimelineTabs.esql}`;
const defaultProps = {
renderCellValue: () => {},
rowRenderers: [],
timelineId: TimelineId.test,
timelineType: TimelineType.default,
timelineDescription: '',
};
it('should show the esql tab', () => {
render(
<TestProviders>
<TabsContent {...defaultProps} />
</TestProviders>
);
expect(screen.getByTestId(esqlTabSubj)).toBeVisible();
});
it('should not show the esql tab when the advanced setting is disabled', async () => {
useEsqlAvailabilityMock.mockReturnValue({
isESQLTabInTimelineEnabled: false,
});
render(
<TestProviders>
<TabsContent {...defaultProps} />
</TestProviders>
);
await waitFor(() => {
expect(screen.queryByTestId(esqlTabSubj)).toBeNull();
});
});
it('should show the esql tab when the advanced setting is disabled, but an esql query is present', async () => {
useEsqlAvailabilityMock.mockReturnValue({
isESQLTabInTimelineEnabled: false,
});
const stateWithSavedSearchId = structuredClone(mockGlobalState);
stateWithSavedSearchId.timeline.timelineById[TimelineId.test].savedSearchId = 'test-id';
const mockStore = createMockStore(stateWithSavedSearchId);
render(
<TestProviders store={mockStore}>
<TabsContent {...defaultProps} />
</TestProviders>
);
await waitFor(() => {
expect(screen.queryByTestId(esqlTabSubj)).toBeVisible();
});
});
});
});

View file

@ -13,8 +13,8 @@ import React, { lazy, memo, Suspense, useCallback, useEffect, useMemo, useState
import { useDispatch } from 'react-redux';
import styled from 'styled-components';
import { useEsqlAvailability } from '../../../../common/hooks/esql/use_esql_availability';
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
import { useKibana } from '../../../../common/lib/kibana';
import { useAssistantTelemetry } from '../../../../assistant/use_assistant_telemetry';
import { useAssistantAvailability } from '../../../../assistant/use_assistant_availability';
import type { SessionViewConfig } from '../../../../../common/types';
@ -43,6 +43,7 @@ import * as i18n from './translations';
import { useLicense } from '../../../../common/hooks/use_license';
import { TIMELINE_CONVERSATION_TITLE } from '../../../../assistant/content/conversations/translations';
import { initializeTimelineSettings } from '../../../store/actions';
import { selectTimelineESQLSavedSearchId } from '../../../store/selectors';
const HideShowContainer = styled.div.attrs<{ $isVisible: boolean; isOverflowYScroll: boolean }>(
({ $isVisible = false, isOverflowYScroll = false }) => ({
@ -109,7 +110,11 @@ const ActiveTimelineTab = memo<ActiveTimelineTabProps>(
showTimeline,
}) => {
const { hasAssistantPrivilege } = useAssistantAvailability();
const isEsqlSettingEnabled = useKibana().services.configSettings.ESQLEnabled;
const { isESQLTabInTimelineEnabled } = useEsqlAvailability();
const timelineESQLSavedSearch = useShallowEqualSelector((state) =>
selectTimelineESQLSavedSearchId(state, timelineId)
);
const shouldShowESQLTab = isESQLTabInTimelineEnabled || timelineESQLSavedSearch != null;
const aiAssistantFlyoutMode = useIsExperimentalFeatureEnabled('aiAssistantFlyoutMode');
const getTab = useCallback(
(tab: TimelineTabs) => {
@ -177,7 +182,7 @@ const ActiveTimelineTab = memo<ActiveTimelineTabProps>(
timelineId={timelineId}
/>
</HideShowContainer>
{showTimeline && isEsqlSettingEnabled && activeTimelineTab === TimelineTabs.esql && (
{showTimeline && shouldShowESQLTab && activeTimelineTab === TimelineTabs.esql && (
<HideShowContainer
$isVisible={true}
data-test-subj={`timeline-tab-content-${TimelineTabs.esql}`}
@ -257,9 +262,7 @@ const TabsContentComponent: React.FC<BasicTimelineTab> = ({
sessionViewConfig,
timelineDescription,
}) => {
const isEsqlTabInTimelineDisabled = useIsExperimentalFeatureEnabled('timelineEsqlTabDisabled');
const aiAssistantFlyoutMode = useIsExperimentalFeatureEnabled('aiAssistantFlyoutMode');
const isEsqlSettingEnabled = useKibana().services.configSettings.ESQLEnabled;
const { hasAssistantPrivilege } = useAssistantAvailability();
const dispatch = useDispatch();
const getActiveTab = useMemo(() => getActiveTabSelector(), []);
@ -268,9 +271,14 @@ const TabsContentComponent: React.FC<BasicTimelineTab> = ({
const getAppNotes = useMemo(() => getNotesSelector(), []);
const getTimelineNoteIds = useMemo(() => getNoteIdsSelector(), []);
const getTimelinePinnedEventNotes = useMemo(() => getEventIdToNoteIdsSelector(), []);
const { isESQLTabInTimelineEnabled } = useEsqlAvailability();
const timelineESQLSavedSearch = useShallowEqualSelector((state) =>
selectTimelineESQLSavedSearchId(state, timelineId)
);
const activeTab = useShallowEqualSelector((state) => getActiveTab(state, timelineId));
const showTimeline = useShallowEqualSelector((state) => getShowTimeline(state, timelineId));
const shouldShowESQLTab = isESQLTabInTimelineEnabled || timelineESQLSavedSearch != null;
const numberOfPinnedEvents = useShallowEqualSelector((state) =>
getNumberOfPinnedEvents(state, timelineId)
@ -373,7 +381,7 @@ const TabsContentComponent: React.FC<BasicTimelineTab> = ({
<span>{i18n.QUERY_TAB}</span>
{showTimeline && <TimelineEventsCountBadge />}
</StyledEuiTab>
{!isEsqlTabInTimelineDisabled && isEsqlSettingEnabled && (
{shouldShowESQLTab && (
<StyledEuiTab
data-test-subj={`timelineTabs-${TimelineTabs.esql}`}
onClick={setEsqlAsActiveTab}

View file

@ -116,6 +116,14 @@ const selectTimelineType = createSelector(selectTimelineById, (timeline) => time
*/
const selectTimelineKqlQuery = createSelector(selectTimelineById, (timeline) => timeline?.kqlQuery);
/**
* Selector that returns the timeline esql saved search id.
*/
export const selectTimelineESQLSavedSearchId = createSelector(
selectTimelineById,
(timeline) => timeline?.savedSearchId
);
/**
* Selector that returns the kqlQuery.filterQuery.kuery.expression of a timeline.
*/

View file

@ -431,7 +431,7 @@ export const previewRulesRoute = (
);
break;
case 'esql':
if (!config.settings.ESQLEnabled || config.experimentalFeatures.esqlRulesDisabled) {
if (config.experimentalFeatures.esqlRulesDisabled) {
throw Error('ES|QL rule type is not supported');
}
const esqlAlertType = previewRuleTypeWrapper(createEsqlAlertType(ruleOptions));

View file

@ -320,7 +320,7 @@ export class Plugin implements ISecuritySolutionPlugin {
const securityRuleTypeWrapper = createSecurityRuleTypeWrapper(securityRuleTypeOptions);
plugins.alerting.registerType(securityRuleTypeWrapper(createEqlAlertType(ruleOptions)));
if (config.settings.ESQLEnabled && !experimentalFeatures.esqlRulesDisabled) {
if (!experimentalFeatures.esqlRulesDisabled) {
plugins.alerting.registerType(securityRuleTypeWrapper(createEsqlAlertType(ruleOptions)));
}
plugins.alerting.registerType(

View file

@ -20,25 +20,19 @@ describe('Detection ES|QL rules, creation', { tags: ['@serverless'] }, () => {
login();
});
it('does not display ES|QL rule on form', function () {
it('should display ES|QL rule on form', function () {
visit(CREATE_RULE_URL);
// ensure, page is loaded and rule types are displayed
cy.get(NEW_TERMS_TYPE).should('be.visible');
cy.get(THRESHOLD_TYPE).should('be.visible');
// ES|QL rule tile should not be rendered
cy.get(ESQL_TYPE).should('not.exist');
cy.get(ESQL_TYPE).should('exist');
});
it('does not allow to create rule by API call', function () {
it('allow creation rule by API call', function () {
createRule(getEsqlRule()).then((response) => {
expect(response.status).to.equal(400);
expect(response.body).to.deep.equal({
status_code: 400,
message: 'Rule type "siem.esqlRule" is not registered.',
});
expect(response.status).to.equal(200);
});
});
});