[Security Solution][Notes] Make MAX_UNASSOCIATED_NOTES an advanced Kibana setting (#194947)

## Summary

Fixes: https://github.com/elastic/kibana/issues/193097

Adds a new Kibana advanced setting that allows users to limit the
maximum amount of unassociated notes. The max value for that used to be
hard coded before.


https://github.com/user-attachments/assets/34af7f67-9109-4251-a5a3-a1af68f123fe


### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Jan Monschke 2024-10-14 19:14:11 +02:00 committed by GitHub
parent 424ffbaffc
commit 925329ec84
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 220 additions and 77 deletions

View file

@ -22,6 +22,10 @@ export const stackManagementSchema: MakeSchemaFrom<UsageStats> = {
_meta: { description: 'Non-default value of setting.' },
},
},
'securitySolution:maxUnassociatedNotes': {
type: 'integer',
_meta: { description: 'The maximum number of allowed unassociated notes' },
},
'securitySolution:defaultThreatIndex': {
type: 'keyword',
_meta: { description: 'Default value of the setting was changed.' },

View file

@ -183,5 +183,6 @@ export interface UsageStats {
'aiAssistant:preferredAIAssistantType': string;
'observability:profilingFetchTopNFunctionsFromStacktraces': boolean;
'securitySolution:excludedDataTiersForRuleExecution': string[];
'securitySolution:maxUnassociatedNotes': number;
'observability:searchExcludedDataTiers': string[];
}

View file

@ -9888,6 +9888,12 @@
}
}
},
"securitySolution:maxUnassociatedNotes": {
"type": "integer",
"_meta": {
"description": "The maximum number of allowed unassociated notes"
}
},
"securitySolution:defaultThreatIndex": {
"type": "keyword",
"_meta": {
@ -10050,7 +10056,7 @@
"description": "Non-default value of setting."
}
},
"securitySolution:enableVisualizationsInFlyout":{
"securitySolution:enableVisualizationsInFlyout": {
"type": "boolean",
"_meta": {
"description": "Non-default value of setting."

View file

@ -71,6 +71,7 @@ export const SECURITY_TAG_NAME = 'Security Solution' as const;
export const SECURITY_TAG_DESCRIPTION = 'Security Solution auto-generated tag' as const;
export const DEFAULT_SPACE_ID = 'default' as const;
export const DEFAULT_RELATIVE_DATE_THRESHOLD = 24 as const;
export const DEFAULT_MAX_UNASSOCIATED_NOTES = 1000 as const;
// Document path where threat indicator fields are expected. Fields are used
// to enrich signals, and are copied to threat.enrichments.
@ -200,6 +201,9 @@ export const ENABLE_ASSET_CRITICALITY_SETTING = 'securitySolution:enableAssetCri
export const EXCLUDED_DATA_TIERS_FOR_RULE_EXECUTION =
'securitySolution:excludedDataTiersForRuleExecution' as const;
/** This Kibana Advances setting allows users to define the maximum amount of unassociated notes (notes without a `timelineId`) */
export const MAX_UNASSOCIATED_NOTES = 'securitySolution:maxUnassociatedNotes' as const;
/** This Kibana Advanced Setting allows users to enable/disable the Visualizations in Flyout feature */
export const ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING =
'securitySolution:enableVisualizationsInFlyout' as const;

View file

@ -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 type { PersistNoteRouteResponse } from '../../../common/api/timeline';
import { KibanaServices } from '../../common/lib/kibana';
import * as api from './api';
jest.mock('../../common/lib/kibana', () => {
return {
KibanaServices: {
get: jest.fn(),
},
};
});
describe('Notes API client', () => {
beforeAll(() => {
jest.resetAllMocks();
});
describe('create note', () => {
it('should throw an error when a response code other than 200 is returned', async () => {
const errorResponse: PersistNoteRouteResponse = {
data: {
persistNote: {
code: 500,
message: 'Internal server error',
note: {
timelineId: '1',
noteId: '2',
version: '3',
},
},
},
};
(KibanaServices.get as jest.Mock).mockReturnValue({
http: {
patch: jest.fn().mockReturnValue(errorResponse),
},
});
expect(async () =>
api.createNote({
note: {
timelineId: '1',
},
})
).rejects.toThrow();
});
});
});

View file

@ -5,7 +5,12 @@
* 2.0.
*/
import type { BareNote, Note } from '../../../common/api/timeline';
import type {
BareNote,
DeleteNoteResponse,
GetNotesResponse,
PersistNoteRouteResponse,
} from '../../../common/api/timeline';
import { KibanaServices } from '../../common/lib/kibana';
import { NOTE_URL } from '../../../common/constants';
@ -16,16 +21,18 @@ import { NOTE_URL } from '../../../common/constants';
*/
export const createNote = async ({ note }: { note: BareNote }) => {
try {
const response = await KibanaServices.get().http.patch<{
data: { persistNote: { code: number; message: string; note: Note } };
}>(NOTE_URL, {
const response = await KibanaServices.get().http.patch<PersistNoteRouteResponse>(NOTE_URL, {
method: 'PATCH',
body: JSON.stringify({ note }),
version: '2023-10-31',
});
return response.data.persistNote.note;
const noteResponse = response.data.persistNote;
if (noteResponse.code !== 200) {
throw new Error(noteResponse.message);
}
return noteResponse.note;
} catch (err) {
throw new Error(`Failed to stringify query: ${JSON.stringify(err)}`);
throw new Error(('message' in err && err.message) || 'Request failed');
}
};
@ -44,20 +51,17 @@ export const fetchNotes = async ({
filter: string;
search: string;
}) => {
const response = await KibanaServices.get().http.get<{ totalCount: number; notes: Note[] }>(
NOTE_URL,
{
query: {
page,
perPage,
sortField,
sortOrder,
filter,
search,
},
version: '2023-10-31',
}
);
const response = await KibanaServices.get().http.get<GetNotesResponse>(NOTE_URL, {
query: {
page,
perPage,
sortField,
sortOrder,
filter,
search,
},
version: '2023-10-31',
});
return response;
};
@ -65,13 +69,10 @@ export const fetchNotes = async ({
* Fetches all the notes for an array of document ids
*/
export const fetchNotesByDocumentIds = async (documentIds: string[]) => {
const response = await KibanaServices.get().http.get<{ notes: Note[]; totalCount: number }>(
NOTE_URL,
{
query: { documentIds },
version: '2023-10-31',
}
);
const response = await KibanaServices.get().http.get<GetNotesResponse>(NOTE_URL, {
query: { documentIds },
version: '2023-10-31',
});
return response;
};
@ -79,13 +80,10 @@ export const fetchNotesByDocumentIds = async (documentIds: string[]) => {
* Fetches all the notes for an array of saved object ids
*/
export const fetchNotesBySaveObjectIds = async (savedObjectIds: string[]) => {
const response = await KibanaServices.get().http.get<{ notes: Note[]; totalCount: number }>(
NOTE_URL,
{
query: { savedObjectIds },
version: '2023-10-31',
}
);
const response = await KibanaServices.get().http.get<GetNotesResponse>(NOTE_URL, {
query: { savedObjectIds },
version: '2023-10-31',
});
return response;
};
@ -93,7 +91,7 @@ export const fetchNotesBySaveObjectIds = async (savedObjectIds: string[]) => {
* Deletes multiple notes
*/
export const deleteNotes = async (noteIds: string[]) => {
const response = await KibanaServices.get().http.delete<{ data: unknown }>(NOTE_URL, {
const response = await KibanaServices.get().http.delete<DeleteNoteResponse>(NOTE_URL, {
body: JSON.stringify({ noteIds }),
version: '2023-10-31',
});

View file

@ -102,6 +102,7 @@ describe('AddNote', () => {
});
it('should render error toast if create a note fails', () => {
const createNoteError = new Error('This error comes from the backend');
const store = createMockStore({
...mockGlobalState,
notes: {
@ -112,7 +113,7 @@ describe('AddNote', () => {
},
error: {
...mockGlobalState.notes.error,
createNote: { type: 'http', status: 500 },
createNote: createNoteError,
},
},
});
@ -123,9 +124,12 @@ describe('AddNote', () => {
</TestProviders>
);
expect(mockAddError).toHaveBeenCalledWith(null, {
title: CREATE_NOTE_ERROR,
});
expect(mockAddError).toHaveBeenCalledWith(
createNoteError,
expect.objectContaining({
title: CREATE_NOTE_ERROR,
})
);
});
it('should call onNodeAdd callback when it is available', async () => {

View file

@ -25,6 +25,7 @@ import {
ReqStatus,
selectCreateNoteError,
selectCreateNoteStatus,
userClosedCreateErrorToast,
} from '../store/notes.slice';
import { MarkdownEditor } from '../../common/components/markdown_editor';
@ -101,14 +102,19 @@ export const AddNote = memo(
setEditorValue('');
}, [dispatch, editorValue, eventId, telemetry, timelineId, onNoteAdd]);
const resetError = useCallback(() => {
dispatch(userClosedCreateErrorToast());
}, [dispatch]);
// show a toast if the create note call fails
useEffect(() => {
if (createStatus === ReqStatus.Failed && createError) {
addErrorToast(null, {
addErrorToast(createError, {
title: CREATE_NOTE_ERROR,
});
resetError();
}
}, [addErrorToast, createError, createStatus]);
}, [addErrorToast, createError, createStatus, resetError]);
const buttonDisabled = useMemo(
() => disableButton || editorValue.trim().length === 0 || isMarkdownInvalid,

View file

@ -46,6 +46,7 @@ import {
fetchNotesBySavedObjectIds,
selectNotesBySavedObjectId,
selectSortedNotesBySavedObjectId,
userClosedCreateErrorToast,
} from './notes.slice';
import type { NotesState } from './notes.slice';
import { mockGlobalState } from '../../common/mock';
@ -533,6 +534,25 @@ describe('notesSlice', () => {
});
});
describe('userClosedCreateErrorToast', () => {
it('should reset create note error', () => {
const action = { type: userClosedCreateErrorToast.type };
expect(
notesReducer(
{
...initalEmptyState,
error: {
...initalEmptyState.error,
createNote: new Error(),
},
},
action
).error.createNote
).toBe(null);
});
});
describe('userSelectedNotesForDeletion', () => {
it('should set correct id when user selects a note to delete', () => {
const action = { type: userSelectedNotesForDeletion.type, payload: '1' };

View file

@ -103,7 +103,7 @@ export const fetchNotesByDocumentIds = createAsyncThunk<
>('notes/fetchNotesByDocumentIds', async (args) => {
const { documentIds } = args;
const res = await fetchNotesByDocumentIdsApi(documentIds);
return normalizeEntities(res.notes);
return normalizeEntities('notes' in res ? res.notes : []);
});
export const fetchNotesBySavedObjectIds = createAsyncThunk<
@ -113,7 +113,7 @@ export const fetchNotesBySavedObjectIds = createAsyncThunk<
>('notes/fetchNotesBySavedObjectIds', async (args) => {
const { savedObjectIds } = args;
const res = await fetchNotesBySaveObjectIdsApi(savedObjectIds);
return normalizeEntities(res.notes);
return normalizeEntities('notes' in res ? res.notes : []);
});
export const fetchNotes = createAsyncThunk<
@ -130,7 +130,10 @@ export const fetchNotes = createAsyncThunk<
>('notes/fetchNotes', async (args) => {
const { page, perPage, sortField, sortOrder, filter, search } = args;
const res = await fetchNotesApi({ page, perPage, sortField, sortOrder, filter, search });
return { ...normalizeEntities(res.notes), totalCount: res.totalCount };
return {
...normalizeEntities('notes' in res ? res.notes : []),
totalCount: 'totalCount' in res ? res.totalCount : 0,
};
});
export const createNote = createAsyncThunk<NormalizedEntity<Note>, { note: BareNote }, {}>(
@ -199,6 +202,9 @@ const notesSlice = createSlice({
userSelectedBulkDelete: (state) => {
state.pendingDeleteIds = state.selectedIds;
},
userClosedCreateErrorToast: (state) => {
state.error.createNote = null;
},
},
extraReducers(builder) {
builder
@ -308,12 +314,12 @@ export const selectFetchNotesError = (state: State) => state.notes.error.fetchNo
export const selectFetchNotesStatus = (state: State) => state.notes.status.fetchNotes;
export const selectNotesByDocumentId = createSelector(
[selectAllNotes, (state: State, documentId: string) => documentId],
[selectAllNotes, (_: State, documentId: string) => documentId],
(notes, documentId) => notes.filter((note) => note.eventId === documentId)
);
export const selectNotesBySavedObjectId = createSelector(
[selectAllNotes, (state: State, savedObjectId: string) => savedObjectId],
[selectAllNotes, (_: State, savedObjectId: string) => savedObjectId],
(notes, savedObjectId) =>
savedObjectId.length > 0 ? notes.filter((note) => note.timelineId === savedObjectId) : []
);
@ -321,10 +327,10 @@ export const selectNotesBySavedObjectId = createSelector(
export const selectDocumentNotesBySavedObjectId = createSelector(
[
selectAllNotes,
(
state: State,
{ documentId, savedObjectId }: { documentId: string; savedObjectId: string }
) => ({ documentId, savedObjectId }),
(_: State, { documentId, savedObjectId }: { documentId: string; savedObjectId: string }) => ({
documentId,
savedObjectId,
}),
],
(notes, { documentId, savedObjectId }) =>
notes.filter((note) => note.eventId === documentId && note.timelineId === savedObjectId)
@ -334,7 +340,7 @@ export const selectSortedNotesByDocumentId = createSelector(
[
selectAllNotes,
(
state: State,
_: State,
{
documentId,
sort,
@ -359,7 +365,7 @@ export const selectSortedNotesBySavedObjectId = createSelector(
[
selectAllNotes,
(
state: State,
_: State,
{
savedObjectId,
sort,
@ -391,6 +397,7 @@ export const {
userSearchedNotes,
userSelectedRow,
userClosedDeleteModal,
userClosedCreateErrorToast,
userSelectedNotesForDeletion,
userSelectedBulkDelete,
} = notesSlice.actions;

View file

@ -6,7 +6,7 @@
*/
import { NOTE_URL } from '../../../../common/constants';
import type { BareNote, Note } from '../../../../common/api/timeline';
import type { BareNote, PersistNoteRouteResponse } from '../../../../common/api/timeline';
import { KibanaServices } from '../../../common/lib/kibana';
export const persistNote = async ({
@ -27,7 +27,7 @@ export const persistNote = async ({
} catch (err) {
return Promise.reject(new Error(`Failed to stringify query: ${JSON.stringify(err)}`));
}
const response = await KibanaServices.get().http.patch<Note[]>(NOTE_URL, {
const response = await KibanaServices.get().http.patch<PersistNoteRouteResponse>(NOTE_URL, {
method: 'PATCH',
body: requestBody,
version: '2023-10-31',

View file

@ -11,11 +11,11 @@ import type { SortOrder } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'
import { buildRouteValidationWithZod } from '@kbn/zod-helpers';
import { timelineSavedObjectType } from '../../saved_object_mappings';
import type { SecuritySolutionPluginRouter } from '../../../../types';
import { NOTE_URL } from '../../../../../common/constants';
import { MAX_UNASSOCIATED_NOTES, NOTE_URL } from '../../../../../common/constants';
import { buildSiemResponse } from '../../../detection_engine/routes/utils';
import { buildFrameworkRequest } from '../../utils/common';
import { getAllSavedNote, MAX_UNASSOCIATED_NOTES } from '../../saved_object/notes';
import { getAllSavedNote } from '../../saved_object/notes';
import { noteSavedObjectType } from '../../saved_object_mappings/notes';
import { GetNotesRequestQuery, type GetNotesResponse } from '../../../../../common/api/timeline';
@ -39,6 +39,10 @@ export const getNotesRoute = (router: SecuritySolutionPluginRouter) => {
try {
const queryParams = request.query;
const frameworkRequest = await buildFrameworkRequest(context, request);
const {
uiSettings: { client: uiSettingsClient },
} = await frameworkRequest.context.core;
const maxUnassociatedNotes = await uiSettingsClient.get<number>(MAX_UNASSOCIATED_NOTES);
const documentIds = queryParams.documentIds ?? null;
const savedObjectIds = queryParams.savedObjectIds ?? null;
if (documentIds != null) {
@ -48,7 +52,7 @@ export const getNotesRoute = (router: SecuritySolutionPluginRouter) => {
type: noteSavedObjectType,
search: docIdSearchString,
page: 1,
perPage: MAX_UNASSOCIATED_NOTES,
perPage: maxUnassociatedNotes,
};
const res = await getAllSavedNote(frameworkRequest, options);
const body: GetNotesResponse = res ?? {};
@ -58,7 +62,7 @@ export const getNotesRoute = (router: SecuritySolutionPluginRouter) => {
type: noteSavedObjectType,
search: documentIds,
page: 1,
perPage: MAX_UNASSOCIATED_NOTES,
perPage: maxUnassociatedNotes,
};
const res = await getAllSavedNote(frameworkRequest, options);
return response.ok({ body: res ?? {} });
@ -73,7 +77,7 @@ export const getNotesRoute = (router: SecuritySolutionPluginRouter) => {
id: soIdSearchString,
},
page: 1,
perPage: MAX_UNASSOCIATED_NOTES,
perPage: maxUnassociatedNotes,
};
const res = await getAllSavedNote(frameworkRequest, options);
const body: GetNotesResponse = res ?? {};
@ -85,7 +89,7 @@ export const getNotesRoute = (router: SecuritySolutionPluginRouter) => {
type: timelineSavedObjectType,
id: savedObjectIds,
},
perPage: MAX_UNASSOCIATED_NOTES,
perPage: maxUnassociatedNotes,
};
const res = await getAllSavedNote(frameworkRequest, options);
const body: GetNotesResponse = res ?? {};

View file

@ -187,6 +187,10 @@ describe('persistNote', () => {
created_at: '2024-06-25T22:56:01.354Z',
created_by: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0',
};
const mockUiSettingsClientGet = jest.fn();
const mockUiSettingsClient = {
get: mockUiSettingsClientGet,
};
const mockSavedObjectClient = savedObjectsClientMock.create();
const core = coreMock.createRequestHandlerContext();
const context = {
@ -197,6 +201,10 @@ describe('persistNote', () => {
...core.savedObjects,
client: mockSavedObjectClient,
},
uiSettings: {
...core.uiSettings,
client: mockUiSettingsClient,
},
},
resolve: jest.fn(),
} as unknown as RequestHandlerContext;
@ -304,7 +312,7 @@ describe('persistNote', () => {
message: 'Cannot create more than 1000 notes without associating them to a timeline',
note: mockNote,
});
mockUiSettingsClientGet.mockResolvedValue(1000);
const result = await persistNote({ request: mockRequest, noteId: null, note: mockNote });
expect(result.code).toBe(403);

View file

@ -16,7 +16,7 @@ import { identity } from 'fp-ts/lib/function';
import type { SavedObjectsFindOptions } from '@kbn/core/server';
import type { AuthenticatedUser } from '@kbn/security-plugin/common';
import { getUserDisplayName } from '@kbn/user-profile-components';
import { UNAUTHENTICATED_USER } from '../../../../../common/constants';
import { MAX_UNASSOCIATED_NOTES, UNAUTHENTICATED_USER } from '../../../../../common/constants';
import type {
Note,
BareNote,
@ -31,8 +31,6 @@ import { noteSavedObjectType } from '../../saved_object_mappings/notes';
import { timelineSavedObjectType } from '../../saved_object_mappings';
import { noteFieldsMigrator } from './field_migrator';
export const MAX_UNASSOCIATED_NOTES = 1000;
export const deleteNotesByTimelineId = async (request: FrameworkRequest, timelineId: string) => {
const options: SavedObjectsFindOptions = {
type: noteSavedObjectType,
@ -135,7 +133,10 @@ export const createNote = async ({
note: BareNote | BareNoteWithoutExternalRefs;
overrideOwner?: boolean;
}): Promise<ResponseNote> => {
const savedObjectsClient = (await request.context.core).savedObjects.client;
const {
savedObjects: { client: savedObjectsClient },
uiSettings: { client: uiSettingsClient },
} = await request.context.core;
const userInfo = request.user;
const noteWithCreator = overrideOwner ? pickSavedNote(noteId, { ...note }, userInfo) : note;
@ -145,15 +146,15 @@ export const createNote = async ({
data: noteWithCreator,
});
if (references.length === 1 && references[0].id === '') {
// Limit unassociated events to 1000
const maxUnassociatedNotes = await uiSettingsClient.get<number>(MAX_UNASSOCIATED_NOTES);
const notesCount = await savedObjectsClient.find<SavedObjectNoteWithoutExternalRefs>({
type: noteSavedObjectType,
hasReference: { type: timelineSavedObjectType, id: '' },
});
if (notesCount.total >= MAX_UNASSOCIATED_NOTES) {
if (notesCount.total >= maxUnassociatedNotes) {
return {
code: 403,
message: `Cannot create more than ${MAX_UNASSOCIATED_NOTES} notes without associating them to a timeline`,
message: `Cannot create more than ${maxUnassociatedNotes} notes without associating them to a timeline`,
note: {
...note,
noteId: uuidv1(),

View file

@ -23,14 +23,19 @@ export const buildFrameworkRequest = async (
const coreContext = await context.core;
const savedObjectsClient = coreContext.savedObjects.client;
const user = coreContext.security.authc.getCurrentUser();
const uiSettings = coreContext.uiSettings;
return set<FrameworkRequest>(
'user',
user,
set<KibanaRequest & { context: RequestHandlerContext }>(
'context.core.savedObjects.client',
savedObjectsClient,
request
'context.core.uiSettings',
uiSettings,
set<FrameworkRequest>(
'user',
user,
set<KibanaRequest & { context: RequestHandlerContext }>(
'context.core.savedObjects.client',
savedObjectsClient,
request
)
)
);
};

View file

@ -19,6 +19,7 @@ import {
DEFAULT_INDEX_PATTERN,
DEFAULT_INTERVAL_PAUSE,
DEFAULT_INTERVAL_VALUE,
DEFAULT_MAX_UNASSOCIATED_NOTES,
DEFAULT_RULE_REFRESH_INTERVAL_ON,
DEFAULT_RULE_REFRESH_INTERVAL_VALUE,
DEFAULT_RULES_TABLE_REFRESH_SETTING,
@ -28,6 +29,7 @@ import {
ENABLE_NEWS_FEED_SETTING,
IP_REPUTATION_LINKS_SETTING,
IP_REPUTATION_LINKS_SETTING_DEFAULT,
MAX_UNASSOCIATED_NOTES,
NEWS_FEED_URL_SETTING,
NEWS_FEED_URL_SETTING_DEFAULT,
ENABLE_CCS_READ_WARNING_SETTING,
@ -342,6 +344,26 @@ export const initUiSettings = (
requiresPageReload: true,
schema: schema.arrayOf(schema.string()),
},
[MAX_UNASSOCIATED_NOTES]: {
name: i18n.translate('xpack.securitySolution.uiSettings.maxUnassociatedNotesLabel', {
defaultMessage: 'Maximum amount of unassociated notes',
}),
description: i18n.translate(
'xpack.securitySolution.uiSettings.maxUnassociatedNotesDescription',
{
defaultMessage:
'Defines the maximum amount of unassociated notes (notes that are not assigned to a timeline) that can be created.',
}
),
type: 'number',
value: DEFAULT_MAX_UNASSOCIATED_NOTES,
schema: schema.number({
min: 1,
max: 1000,
defaultValue: DEFAULT_MAX_UNASSOCIATED_NOTES,
}),
requiresPageReload: false,
},
[EXCLUDED_DATA_TIERS_FOR_RULE_EXECUTION]: {
name: i18n.translate(
'xpack.securitySolution.uiSettings.excludedDataTiersForRuleExecutionLabel',
@ -353,8 +375,8 @@ export const initUiSettings = (
'xpack.securitySolution.uiSettings.excludedDataTiersForRuleExecutionDescription',
{
defaultMessage: `
When configured, events from the specified data tiers are not searched during rules executions.
<br/>This might help to improve rule performance or reduce execution time.
When configured, events from the specified data tiers are not searched during rules executions.
<br/>This might help to improve rule performance or reduce execution time.
<br/>If you specify multiple data tiers, separate values with commas. For example: data_frozen,data_cold`,
}
),