[TGrid] Alerts status update use RAC api (#108092)

Co-authored-by: Devin Hurley <devin.hurley@elastic.co>
This commit is contained in:
Sergi Massaneda 2021-08-14 04:11:53 +02:00 committed by GitHub
parent 78e7e40b77
commit a7661a553c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 465 additions and 93 deletions

View file

@ -27,7 +27,7 @@ export const AlertConsumers = {
SYNTHETICS: 'synthetics',
} as const;
export type AlertConsumers = typeof AlertConsumers[keyof typeof AlertConsumers];
export type STATUS_VALUES = 'open' | 'acknowledged' | 'closed';
export type STATUS_VALUES = 'open' | 'acknowledged' | 'closed' | 'in-progress'; // TODO: remove 'in-progress' after migration to 'acknowledged'
export const mapConsumerToIndexName: Record<AlertConsumers, string | string[]> = {
apm: '.alerts-observability-apm',

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import Boom from '@hapi/boom';
import { estypes } from '@elastic/elasticsearch';
import { PublicMethodsOf } from '@kbn/utility-types';
import { Filter, buildEsQuery, EsQueryConfig } from '@kbn/es-query';
import { decodeVersion, encodeHitVersion } from '@kbn/securitysolution-es-utils';
@ -35,7 +36,7 @@ import { Logger, ElasticsearchClient, EcsEventOutcome } from '../../../../../src
import { alertAuditEvent, operationAlertAuditActionMap } from './audit_events';
import { AuditLogger } from '../../../security/server';
import {
ALERT_STATUS,
ALERT_WORKFLOW_STATUS,
ALERT_RULE_CONSUMER,
ALERT_RULE_TYPE_ID,
SPACE_IDS,
@ -50,16 +51,19 @@ const mapConsumerToIndexName: typeof mapConsumerToIndexNameTyped = mapConsumerTo
// TODO: Fix typings https://github.com/elastic/kibana/issues/101776
type NonNullableProps<Obj extends {}, Props extends keyof Obj> = Omit<Obj, Props> &
{ [K in Props]-?: NonNullable<Obj[K]> };
type AlertType = NonNullableProps<
type AlertType = { _index: string; _id: string } & NonNullableProps<
ParsedTechnicalFields,
typeof ALERT_RULE_TYPE_ID | typeof ALERT_RULE_CONSUMER | typeof SPACE_IDS
>;
const isValidAlert = (source?: ParsedTechnicalFields): source is AlertType => {
const isValidAlert = (source?: estypes.SearchHit<any>): source is AlertType => {
return (
source?.[ALERT_RULE_TYPE_ID] != null &&
source?.[ALERT_RULE_CONSUMER] != null &&
source?.[SPACE_IDS] != null
(source?._source?.[ALERT_RULE_TYPE_ID] != null &&
source?._source?.[ALERT_RULE_CONSUMER] != null &&
source?._source?.[SPACE_IDS] != null) ||
(source?.fields?.[ALERT_RULE_TYPE_ID][0] != null &&
source?.fields?.[ALERT_RULE_CONSUMER][0] != null &&
source?.fields?.[SPACE_IDS][0] != null)
);
};
export interface ConstructorOptions {
@ -80,7 +84,7 @@ export interface BulkUpdateOptions<Params extends AlertTypeParams> {
ids: string[] | undefined | null;
status: STATUS_VALUES;
index: string;
query: string | undefined | null;
query: object | string | undefined | null;
}
interface GetAlertParams {
@ -90,7 +94,7 @@ interface GetAlertParams {
interface SingleSearchAfterAndAudit {
id: string | null | undefined;
query: string | null | undefined;
query: object | string | null | undefined;
index?: string;
operation: WriteOperations.Update | ReadOperations.Find | ReadOperations.Get;
lastSortIds: Array<string | number> | undefined;
@ -126,6 +130,15 @@ export class AlertsClient {
};
}
private getAlertStatusFieldUpdate(
source: ParsedTechnicalFields | undefined,
status: STATUS_VALUES
) {
return source?.[ALERT_WORKFLOW_STATUS] == null
? { signal: { status } }
: { [ALERT_WORKFLOW_STATUS]: status };
}
/**
* Accepts an array of ES documents and executes ensureAuthorized for the given operation
* @param items
@ -218,6 +231,7 @@ export class AlertsClient {
const config = getEsQueryConfig();
let queryBody = {
fields: [ALERT_RULE_TYPE_ID, ALERT_RULE_CONSUMER, ALERT_WORKFLOW_STATUS, SPACE_IDS],
query: await this.buildEsQueryWithAuthz(query, id, alertSpaceId, operation, config),
sort: [
{
@ -245,7 +259,7 @@ export class AlertsClient {
seq_no_primary_term: true,
});
if (!result?.body.hits.hits.every((hit) => isValidAlert(hit._source))) {
if (!result?.body.hits.hits.every((hit) => isValidAlert(hit))) {
const errorMessage = `Invalid alert found with id of "${id}" or with query "${query}" and operation ${operation}`;
this.logger.error(errorMessage);
throw Boom.badData(errorMessage);
@ -307,19 +321,25 @@ export class AlertsClient {
);
}
const bulkUpdateRequest = mgetRes.body.docs.flatMap((item) => [
{
update: {
_index: item._index,
_id: item._id,
const bulkUpdateRequest = mgetRes.body.docs.flatMap((item) => {
const fieldToUpdate = this.getAlertStatusFieldUpdate(item?._source, status);
return [
{
update: {
_index: item._index,
_id: item._id,
},
},
},
{
doc: { [ALERT_STATUS]: status },
},
]);
{
doc: {
...fieldToUpdate,
},
},
];
});
const bulkUpdateResponse = await this.esClient.bulk({
refresh: 'wait_for',
body: bulkUpdateRequest,
});
return bulkUpdateResponse;
@ -330,7 +350,7 @@ export class AlertsClient {
}
private async buildEsQueryWithAuthz(
query: string | null | undefined,
query: object | string | null | undefined,
id: string | null | undefined,
alertSpaceId: string,
operation: WriteOperations.Update | ReadOperations.Get | ReadOperations.Find,
@ -345,15 +365,33 @@ export class AlertsClient {
},
operation
);
return buildEsQuery(
let esQuery;
if (id != null) {
esQuery = { query: `_id:${id}`, language: 'kuery' };
} else if (typeof query === 'string') {
esQuery = { query, language: 'kuery' };
} else if (query != null && typeof query === 'object') {
esQuery = [];
}
const builtQuery = buildEsQuery(
undefined,
{ query: query == null ? `_id:${id}` : query, language: 'kuery' },
esQuery == null ? { query: ``, language: 'kuery' } : esQuery,
[
(authzFilter as unknown) as Filter,
({ term: { [SPACE_IDS]: alertSpaceId } } as unknown) as Filter,
],
config
);
if (query != null && typeof query === 'object') {
return {
...builtQuery,
bool: {
...builtQuery.bool,
must: [...builtQuery.bool.must, query],
},
};
}
return builtQuery;
} catch (exc) {
this.logger.error(exc);
throw Boom.expectationFailed(
@ -373,7 +411,7 @@ export class AlertsClient {
operation,
}: {
index: string;
query: string;
query: object | string;
operation: WriteOperations.Update | ReadOperations.Find | ReadOperations.Get;
}) {
let lastSortIds;
@ -436,7 +474,7 @@ export class AlertsClient {
// first search for the alert by id, then use the alert info to check if user has access to it
const alert = await this.singleSearchAfterAndAudit({
id,
query: null,
query: undefined,
index,
operation: ReadOperations.Get,
lastSortIds: undefined,
@ -476,14 +514,17 @@ export class AlertsClient {
this.logger.error(errorMessage);
throw Boom.notFound(errorMessage);
}
const fieldToUpdate = this.getAlertStatusFieldUpdate(
alert?.hits.hits[0]._source,
status as STATUS_VALUES
);
const { body: response } = await this.esClient.update<ParsedTechnicalFields>({
...decodeVersion(_version),
id,
index,
body: {
doc: {
[ALERT_STATUS]: status,
...fieldToUpdate,
},
},
refresh: 'wait_for',
@ -535,11 +576,11 @@ export class AlertsClient {
refresh: true,
body: {
script: {
source: `if (ctx._source['${ALERT_STATUS}'] != null) {
ctx._source['${ALERT_STATUS}'] = '${status}'
source: `if (ctx._source['${ALERT_WORKFLOW_STATUS}'] != null) {
ctx._source['${ALERT_WORKFLOW_STATUS}'] = '${status}'
}
if (ctx._source['signal.status'] != null) {
ctx._source['signal.status'] = '${status}'
if (ctx._source.signal != null && ctx._source.signal.status != null) {
ctx._source.signal.status = '${status}'
}`,
lang: 'painless',
} as InlineScript,

View file

@ -119,6 +119,12 @@ describe('get()', () => {
Array [
Object {
"body": Object {
"fields": Array [
"kibana.alert.rule.rule_type_id",
"kibana.alert.rule.consumer",
"kibana.alert.workflow_status",
"kibana.space_ids",
],
"query": Object {
"bool": Object {
"filter": Array [
@ -254,7 +260,7 @@ describe('get()', () => {
await expect(alertsClient.get({ id: fakeAlertId, index: '.alerts-observability-apm' })).rejects
.toThrowErrorMatchingInlineSnapshot(`
"Unable to retrieve alert details for alert with id of \\"myfakeid1\\" or with query \\"null\\" and operation get
"Unable to retrieve alert details for alert with id of \\"myfakeid1\\" or with query \\"undefined\\" and operation get
Error: Error: Unauthorized for fake.rule and apm"
`);
@ -281,7 +287,7 @@ describe('get()', () => {
await expect(
alertsClient.get({ id: 'NoxgpHkBqbdrfX07MqXV', index: '.alerts-observability-apm' })
).rejects.toThrowErrorMatchingInlineSnapshot(`
"Unable to retrieve alert details for alert with id of \\"NoxgpHkBqbdrfX07MqXV\\" or with query \\"null\\" and operation get
"Unable to retrieve alert details for alert with id of \\"NoxgpHkBqbdrfX07MqXV\\" or with query \\"undefined\\" and operation get
Error: Error: something went wrong"
`);
});

View file

@ -7,7 +7,7 @@
import {
ALERT_RULE_CONSUMER,
ALERT_STATUS,
ALERT_WORKFLOW_STATUS,
SPACE_IDS,
ALERT_RULE_TYPE_ID,
} from '@kbn/rule-data-utils';
@ -89,8 +89,8 @@ describe('update()', () => {
_source: {
[ALERT_RULE_TYPE_ID]: 'apm.error_rate',
message: 'hello world 1',
[ALERT_WORKFLOW_STATUS]: 'open',
[ALERT_RULE_CONSUMER]: 'apm',
[ALERT_STATUS]: 'open',
[SPACE_IDS]: [DEFAULT_SPACE],
},
},
@ -139,7 +139,7 @@ describe('update()', () => {
Object {
"body": Object {
"doc": Object {
"${ALERT_STATUS}": "closed",
"${ALERT_WORKFLOW_STATUS}": "closed",
},
},
"id": "1",
@ -175,8 +175,8 @@ describe('update()', () => {
_source: {
[ALERT_RULE_TYPE_ID]: 'apm.error_rate',
message: 'hello world 1',
[ALERT_WORKFLOW_STATUS]: 'open',
[ALERT_RULE_CONSUMER]: 'apm',
[ALERT_STATUS]: 'open',
[SPACE_IDS]: [DEFAULT_SPACE],
},
},
@ -249,7 +249,7 @@ describe('update()', () => {
_source: {
[ALERT_RULE_TYPE_ID]: fakeRuleTypeId,
[ALERT_RULE_CONSUMER]: 'apm',
[ALERT_STATUS]: 'open',
[ALERT_WORKFLOW_STATUS]: 'open',
[SPACE_IDS]: [DEFAULT_SPACE],
},
},
@ -330,8 +330,8 @@ describe('update()', () => {
_source: {
[ALERT_RULE_TYPE_ID]: 'apm.error_rate',
message: 'hello world 1',
[ALERT_WORKFLOW_STATUS]: 'open',
[ALERT_RULE_CONSUMER]: 'apm',
[ALERT_STATUS]: 'open',
[SPACE_IDS]: [DEFAULT_SPACE],
},
},
@ -391,7 +391,7 @@ describe('update()', () => {
[ALERT_RULE_TYPE_ID]: 'apm.error_rate',
message: 'hello world 1',
[ALERT_RULE_CONSUMER]: 'apm',
[ALERT_STATUS]: 'open',
[ALERT_WORKFLOW_STATUS]: 'open',
[SPACE_IDS]: [DEFAULT_SPACE],
},
},

View file

@ -22,16 +22,26 @@ export const bulkUpdateAlertsRoute = (router: IRouter<RacRequestHandlerContext>)
body: buildRouteValidation(
t.union([
t.strict({
status: t.union([t.literal('open'), t.literal('closed')]),
status: t.union([
t.literal('open'),
t.literal('closed'),
t.literal('in-progress'), // TODO: remove after migration to acknowledged
t.literal('acknowledged'),
]),
index: t.string,
ids: t.array(t.string),
query: t.undefined,
}),
t.strict({
status: t.union([t.literal('open'), t.literal('closed')]),
status: t.union([
t.literal('open'),
t.literal('closed'),
t.literal('in-progress'), // TODO: remove after migration to acknowledged
t.literal('acknowledged'),
]),
index: t.string,
ids: t.undefined,
query: t.string,
query: t.union([t.object, t.string]),
}),
])
),

View file

@ -53,7 +53,6 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps> = ({
timelineId,
}) => {
const [isPopoverOpen, setPopover] = useState(false);
const ruleId = get(0, ecsRowData?.signal?.rule?.id);
const ruleName = get(0, ecsRowData?.signal?.rule?.name);
@ -116,6 +115,7 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps> = ({
const { actionItems } = useAlertsActions({
alertStatus,
eventId: ecsRowData?._id,
indexName: ecsRowData?._index ?? '',
timelineId,
closePopover,
});

View file

@ -27,9 +27,16 @@ interface Props {
closePopover: () => void;
eventId: string;
timelineId: string;
indexName: string;
}
export const useAlertsActions = ({ alertStatus, closePopover, eventId, timelineId }: Props) => {
export const useAlertsActions = ({
alertStatus,
closePopover,
eventId,
timelineId,
indexName,
}: Props) => {
const dispatch = useDispatch();
const [, dispatchToaster] = useStateToaster();
@ -100,6 +107,7 @@ export const useAlertsActions = ({ alertStatus, closePopover, eventId, timelineI
const actionItems = useStatusBulkActionItems({
eventIds: [eventId],
currentStatus: alertStatus,
indexName,
setEventsLoading,
setEventsDeleted,
onUpdateSuccess: onAlertStatusUpdateSuccess,

View file

@ -48,6 +48,7 @@ export const TakeActionDropdown = React.memo(
onAddExceptionTypeClick,
onAddIsolationStatusClick,
refetch,
indexName,
timelineId,
}: {
detailsData: TimelineEventsDetailsItem[] | null;
@ -57,6 +58,7 @@ export const TakeActionDropdown = React.memo(
loadingEventDetails: boolean;
nonEcsData?: TimelineNonEcsData[];
refetch: (() => void) | undefined;
indexName: string;
onAddEventFilterClick: () => void;
onAddExceptionTypeClick: (type: ExceptionListType) => void;
onAddIsolationStatusClick: (action: 'isolateHost' | 'unisolateHost') => void;
@ -154,6 +156,7 @@ export const TakeActionDropdown = React.memo(
const { actionItems } = useAlertsActions({
alertStatus: actionsData.alertStatus,
eventId: actionsData.eventId,
indexName,
timelineId,
closePopover: closePopoverAndFlyout,
});

View file

@ -487,6 +487,7 @@ Array [
<Memo()
detailsData={null}
handleOnEventClosed={[Function]}
indexName="my-index"
isHostIsolationPanelOpen={false}
loadingEventDetails={true}
onAddEventFilterClick={[Function]}
@ -747,6 +748,7 @@ Array [
<Memo()
detailsData={null}
handleOnEventClosed={[Function]}
indexName="my-index"
isHostIsolationPanelOpen={false}
loadingEventDetails={true}
onAddEventFilterClick={[Function]}

View file

@ -113,6 +113,7 @@ export const EventDetailsFooter = React.memo(
onAddExceptionTypeClick={onAddExceptionTypeClick}
onAddIsolationStatusClick={onAddIsolationStatusClick}
refetch={expandedEvent?.refetch}
indexName={expandedEvent.indexName}
timelineId={timelineId}
/>
</EuiFlexItem>

View file

@ -13,3 +13,5 @@ export const DEFAULT_NUMBER_FORMAT = 'format:number:defaultPattern';
export const FILTER_OPEN: AlertStatus = 'open';
export const FILTER_CLOSED: AlertStatus = 'closed';
export const FILTER_IN_PROGRESS: AlertStatus = 'in-progress';
export const RAC_ALERTS_BULK_UPDATE_URL = '/internal/rac/alerts/bulk_update';

View file

@ -80,6 +80,8 @@ describe('Body', () => {
leadingControlColumns: [],
trailingControlColumns: [],
filterStatus: 'open',
filterQuery: '',
indexNames: [''],
refetch: jest.fn(),
};

View file

@ -74,6 +74,7 @@ interface OwnProps {
activePage: number;
additionalControls?: React.ReactNode;
browserFields: BrowserFields;
filterQuery: string;
data: TimelineItem[];
defaultCellActions?: TGridCellAction[];
id: string;
@ -90,6 +91,7 @@ interface OwnProps {
filterStatus?: AlertStatus;
unit?: (total: number) => React.ReactNode;
onRuleChange?: () => void;
indexNames: string[];
refetch: Refetch;
}
@ -225,6 +227,7 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
activePage,
additionalControls,
browserFields,
filterQuery,
columnHeaders,
data,
defaultCellActions,
@ -250,6 +253,7 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
unit = basicUnit,
leadingControlColumns = EMPTY_CONTROL_COLUMNS,
trailingControlColumns = EMPTY_CONTROL_COLUMNS,
indexNames,
refetch,
}) => {
const dispatch = useDispatch();
@ -337,6 +341,8 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
id={id}
totalItems={totalItems}
filterStatus={filterStatus}
query={filterQuery}
indexName={indexNames.join()}
onActionSuccess={onAlertStatusActionSuccess}
onActionFailure={onAlertStatusActionFailure}
refetch={refetch}
@ -375,7 +381,9 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
alertCountText,
totalItems,
filterStatus,
filterQuery,
browserFields,
indexNames,
columnHeaders,
additionalControls,
showBulkActions,

View file

@ -6,8 +6,10 @@
*/
import type { Filter, EsQueryConfig, Query } from '@kbn/es-query';
import { FilterStateStore } from '@kbn/es-query';
import { isEmpty, get } from 'lodash/fp';
import memoizeOne from 'memoize-one';
import { ALERT_WORKFLOW_STATUS } from '@kbn/rule-data-utils';
import {
elementOrChildrenHasFocus,
getFocusedAriaColindexCell,
@ -15,7 +17,7 @@ import {
handleSkipFocus,
stopPropagationAndPreventDefault,
} from '../../../common';
import type { IIndexPattern } from '../../../../../../src/plugins/data/public';
import { IIndexPattern } from '../../../../../../src/plugins/data/public';
import type { BrowserFields } from '../../../common/search_strategy/index_fields';
import { DataProviderType, EXISTS_OPERATOR } from '../../../common/types/timeline';
import type { DataProvider, DataProvidersAnd } from '../../../common/types/timeline';
@ -133,6 +135,17 @@ export const buildGlobalQuery = (dataProviders: DataProvider[], browserFields: B
return !index ? `(${queryMatch})` : `${globalQuery} or (${queryMatch})`;
}, '');
interface CombineQueries {
config: EsQueryConfig;
dataProviders: DataProvider[];
indexPattern: IIndexPattern;
browserFields: BrowserFields;
filters: Filter[];
kqlQuery: Query;
kqlMode: string;
isEventViewer?: boolean;
}
export const combineQueries = ({
config,
dataProviders,
@ -142,16 +155,7 @@ export const combineQueries = ({
kqlQuery,
kqlMode,
isEventViewer,
}: {
config: EsQueryConfig;
dataProviders: DataProvider[];
indexPattern: IIndexPattern;
browserFields: BrowserFields;
filters: Filter[];
kqlQuery: Query;
kqlMode: string;
isEventViewer?: boolean;
}): { filterQuery: string } | null => {
}: CombineQueries): { filterQuery: string } | null => {
const kuery: Query = { query: '', language: kqlQuery.language };
if (isEmpty(dataProviders) && isEmpty(kqlQuery.query) && isEmpty(filters) && !isEventViewer) {
return null;
@ -184,6 +188,64 @@ export const combineQueries = ({
};
};
export const buildCombinedQuery = (combineQueriesParams: CombineQueries) => {
const combinedQuery = combineQueries(combineQueriesParams);
return combinedQuery
? {
filterQuery: replaceStatusField(combinedQuery!.filterQuery),
}
: null;
};
export const buildTimeRangeFilter = (from: string, to: string): Filter =>
({
range: {
'@timestamp': {
gte: from,
lt: to,
format: 'strict_date_optional_time',
},
},
meta: {
type: 'range',
disabled: false,
negate: false,
alias: null,
key: '@timestamp',
params: {
gte: from,
lt: to,
format: 'strict_date_optional_time',
},
},
$state: {
store: FilterStateStore.APP_STATE,
},
} as Filter);
export const getCombinedFilterQuery = ({
from,
to,
filters,
...combineQueriesParams
}: CombineQueries & { from: string; to: string }): string => {
return replaceStatusField(
combineQueries({
...combineQueriesParams,
filters: [...filters, buildTimeRangeFilter(from, to)],
})!.filterQuery
);
};
/**
* This function is a temporary patch to prevent queries using old `signal.status` field.
* @todo The `signal.status` field should not be queried anymore and
* must be replaced by `ALERT_WORKFLOW_STATUS` field name constant
* @deprecated
*/
const replaceStatusField = (query: string): string =>
query.replaceAll('signal.status', ALERT_WORKFLOW_STATUS);
/**
* The CSS class name of a "stateful event", which appears in both
* the `Timeline` and the `Events Viewer` widget

View file

@ -37,7 +37,12 @@ import {
} from '../../../../../../../src/plugins/data/public';
import { useDeepEqualSelector } from '../../../hooks/use_selector';
import { defaultHeaders } from '../body/column_headers/default_headers';
import { calculateTotalPages, combineQueries, resolverIsShowing } from '../helpers';
import {
calculateTotalPages,
buildCombinedQuery,
getCombinedFilterQuery,
resolverIsShowing,
} from '../helpers';
import { tGridActions, tGridSelectors } from '../../../store/t_grid';
import { useTimelineEvents } from '../../../container';
import { HeaderSection } from '../header_section';
@ -203,7 +208,7 @@ const TGridIntegratedComponent: React.FC<TGridIntegratedProps> = ({
[globalFullScreen, justTitle, setGlobalFullScreen]
);
const combinedQueries = combineQueries({
const combinedQueries = buildCombinedQuery({
config: esQuery.getEsQueryConfig(uiSettings),
dataProviders,
indexPattern,
@ -257,6 +262,21 @@ const TGridIntegratedComponent: React.FC<TGridIntegratedProps> = ({
data,
});
const filterQuery = useMemo(() => {
return getCombinedFilterQuery({
config: esQuery.getEsQueryConfig(uiSettings),
dataProviders,
indexPattern,
browserFields,
filters,
kqlQuery: query,
kqlMode,
isEventViewer: true,
from: start,
to: end,
});
}, [uiSettings, dataProviders, indexPattern, browserFields, filters, start, end, query, kqlMode]);
const totalCountMinusDeleted = useMemo(
() => (totalCount > 0 ? totalCount - deletedEventIds.length : 0),
[deletedEventIds.length, totalCount]
@ -315,6 +335,7 @@ const TGridIntegratedComponent: React.FC<TGridIntegratedProps> = ({
<StatefulBody
activePage={pageInfo.activePage}
browserFields={browserFields}
filterQuery={filterQuery}
data={nonDeletedEvents}
defaultCellActions={defaultCellActions}
id={id}
@ -334,6 +355,7 @@ const TGridIntegratedComponent: React.FC<TGridIntegratedProps> = ({
leadingControlColumns={leadingControlColumns}
trailingControlColumns={trailingControlColumns}
refetch={refetch}
indexNames={indexNames}
/>
<Footer
activePage={pageInfo.activePage}

View file

@ -33,7 +33,12 @@ import {
} from '../../../../../../../src/plugins/data/public';
import { useDeepEqualSelector } from '../../../hooks/use_selector';
import { defaultHeaders } from '../body/column_headers/default_headers';
import { calculateTotalPages, combineQueries, resolverIsShowing } from '../helpers';
import {
calculateTotalPages,
combineQueries,
getCombinedFilterQuery,
resolverIsShowing,
} from '../helpers';
import { tGridActions, tGridSelectors } from '../../../store/t_grid';
import { useTimelineEvents } from '../../../container';
import { HeaderSection } from '../header_section';
@ -148,7 +153,7 @@ const TGridStandaloneComponent: React.FC<TGridStandaloneProps> = ({
rowRenderers,
setRefetch,
start,
sort: initialSort,
sort,
graphEventId,
leadingControlColumns,
trailingControlColumns,
@ -166,7 +171,6 @@ const TGridStandaloneComponent: React.FC<TGridStandaloneProps> = ({
itemsPerPage: itemsPerPageStore,
itemsPerPageOptions: itemsPerPageOptionsStore,
queryFields,
sort: sortFromRedux,
title,
} = useDeepEqualSelector((state) => getTGrid(state, STANDALONE_ID ?? ''));
@ -207,9 +211,6 @@ const TGridStandaloneComponent: React.FC<TGridStandaloneProps> = ({
[columnsHeader, queryFields]
);
const [sort, setSort] = useState(initialSort);
useEffect(() => setSort(sortFromRedux), [sortFromRedux]);
const sortField = useMemo(
() =>
sort.map(({ columnId, columnType, sortDirection }) => ({
@ -263,6 +264,23 @@ const TGridStandaloneComponent: React.FC<TGridStandaloneProps> = ({
[headerFilterGroup, graphEventId]
);
const filterQuery = useMemo(
() =>
getCombinedFilterQuery({
config: esQuery.getEsQueryConfig(uiSettings),
dataProviders: EMPTY_DATA_PROVIDERS,
indexPattern: indexPatterns,
browserFields,
filters,
kqlQuery: query,
kqlMode: 'search',
isEventViewer: true,
from: start,
to: end,
}),
[uiSettings, indexPatterns, browserFields, filters, query, start, end]
);
useEffect(() => {
setIsQueryLoading(loading);
}, [loading]);
@ -288,7 +306,6 @@ const TGridStandaloneComponent: React.FC<TGridStandaloneProps> = ({
defaultColumns: columns,
footerText,
loadingText,
sort,
unit,
})
);
@ -341,6 +358,8 @@ const TGridStandaloneComponent: React.FC<TGridStandaloneProps> = ({
itemsPerPage: itemsPerPageStore,
})}
totalItems={totalCountMinusDeleted}
indexNames={indexNames}
filterQuery={filterQuery}
unit={unit}
filterStatus={filterStatus}
leadingControlColumns={leadingControlColumns}

View file

@ -27,6 +27,8 @@ interface OwnProps {
id: string;
totalItems: number;
filterStatus?: AlertStatus;
query: string;
indexName: string;
onActionSuccess?: OnAlertStatusActionSuccess;
onActionFailure?: OnAlertStatusActionFailure;
refetch: Refetch;
@ -42,9 +44,11 @@ export const AlertStatusBulkActionsComponent = React.memo<StatefulAlertStatusBul
id,
totalItems,
filterStatus,
query,
selectedEventIds,
isSelectAllChecked,
clearSelected,
indexName,
onActionSuccess,
onActionFailure,
refetch,
@ -145,8 +149,10 @@ export const AlertStatusBulkActionsComponent = React.memo<StatefulAlertStatusBul
);
const statusBulkActionItems = useStatusBulkActionItems({
currentStatus: filterStatus,
indexName,
eventIds: Object.keys(selectedEventIds),
currentStatus: filterStatus,
...(showClearSelection ? { query } : {}),
setEventsLoading,
setEventsDeleted,
onUpdateSuccess: onAlertStatusUpdateSuccess,

View file

@ -6,34 +6,37 @@
*/
import type { estypes } from '@elastic/elasticsearch';
import { CoreStart } from '../../../../../src/core/public';
import { useKibana } from '../../../../../src/plugins/kibana_react/public';
import { AlertStatus } from '../../../timelines/common';
export const DETECTION_ENGINE_SIGNALS_STATUS_URL = '/api/detection_engine/signals/status';
import { RAC_ALERTS_BULK_UPDATE_URL } from '../../common/constants';
/**
* Update alert status by query
*
* @param query of alerts to update
* @param status to update to('open' / 'closed' / 'in-progress')
* @param signal AbortSignal for cancelling request
* @param status to update to('open' / 'closed' / 'acknowledged')
* @param index index to be updated
* @param query optional query object to update alerts by query.
* @param ids optional array of alert ids to update. Ignored if query passed.
*
* @throws An error if response is not OK
*/
export const useUpdateAlertsStatus = (): {
updateAlertStatus: (params: {
query: object;
status: AlertStatus;
index: string;
ids?: string[];
query?: object;
}) => Promise<estypes.UpdateByQueryResponse>;
} => {
const { http } = useKibana().services;
const { http } = useKibana<CoreStart>().services;
return {
updateAlertStatus: ({ query, status }) =>
http!.fetch(DETECTION_ENGINE_SIGNALS_STATUS_URL, {
method: 'POST',
body: JSON.stringify({ status, query }),
}),
updateAlertStatus: async ({ status, index, ids, query }) => {
const { body } = await http.post(RAC_ALERTS_BULK_UPDATE_URL, {
body: JSON.stringify({ index, status, ...(query ? { query } : { ids }) }),
});
return body;
},
};
};

View file

@ -26,6 +26,7 @@ export interface StatusBulkActionsProps {
eventIds: string[];
currentStatus?: AlertStatus;
query?: string;
indexName: string;
setEventsLoading: (param: SetEventsLoadingProps) => void;
setEventsDeleted: ({ eventIds, isDeleted }: SetEventsDeletedProps) => void;
onUpdateSuccess: (updated: number, conflicts: number, status: AlertStatus) => void;
@ -40,6 +41,7 @@ export const useStatusBulkActionItems = ({
eventIds,
currentStatus,
query,
indexName,
setEventsLoading,
setEventsDeleted,
onUpdateSuccess,
@ -52,8 +54,12 @@ export const useStatusBulkActionItems = ({
try {
setEventsLoading({ eventIds, isLoading: true });
const queryObject = query ? JSON.parse(query) : getUpdateAlertsQuery(eventIds);
const response = await updateAlertStatus({ query: queryObject, status });
const response = await updateAlertStatus({
index: indexName,
status,
query: query ? JSON.parse(query) : getUpdateAlertsQuery(eventIds),
});
// TODO: Only delete those that were successfully updated from updatedRules
setEventsDeleted({ eventIds, isDeleted: true });
@ -69,10 +75,11 @@ export const useStatusBulkActionItems = ({
}
},
[
eventIds,
query,
setEventsLoading,
eventIds,
updateAlertStatus,
indexName,
query,
setEventsDeleted,
onUpdateSuccess,
onUpdateFailure,

View file

@ -30,5 +30,6 @@ export default ({ loadTestFile }: FtrProviderContext): void => {
loadTestFile(require.resolve('./query_signals'));
loadTestFile(require.resolve('./open_close_signals'));
loadTestFile(require.resolve('./import_timelines'));
loadTestFile(require.resolve('./update_rac_alerts'));
});
};

View file

@ -0,0 +1,152 @@
/*
* 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 expect from '@kbn/expect';
import type { estypes } from '@elastic/elasticsearch';
import { Signal } from '../../../../plugins/security_solution/server/lib/detection_engine/signals/types';
import { DETECTION_ENGINE_QUERY_SIGNALS_URL } from '../../../../plugins/security_solution/common/constants';
import { RAC_ALERTS_BULK_UPDATE_URL } from '../../../../plugins/timelines/common/constants';
import { FtrProviderContext } from '../../common/ftr_provider_context';
import {
createSignalsIndex,
deleteSignalsIndex,
getSignalStatusEmptyResponse,
getQuerySignalIds,
deleteAllAlerts,
createRule,
waitForSignalsToBePresent,
getSignalsByIds,
waitForRuleSuccessOrStatus,
getRuleForSignalTesting,
} from '../../utils';
// eslint-disable-next-line import/no-default-export
export default ({ getService }: FtrProviderContext) => {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
describe('open_close_signals', () => {
describe('validation checks', () => {
it.skip('should not give errors when querying and the signals index does not exist yet', async () => {
const { body } = await supertest
.post(RAC_ALERTS_BULK_UPDATE_URL)
.set('kbn-xsrf', 'true')
.send({ ids: ['123'], status: 'open', index: '.siem-signals-default' });
// remove any server generated items that are indeterministic
delete body.took;
expect(body).to.eql(getSignalStatusEmptyResponse());
});
it('should not give errors when querying and the signals index does exist and is empty', async () => {
await createSignalsIndex(supertest);
await supertest
.post(RAC_ALERTS_BULK_UPDATE_URL)
.set('kbn-xsrf', 'true')
.send({ ids: ['123'], status: 'open', index: '.siem-signals-default' })
.expect(200);
});
});
describe('tests with auditbeat data', () => {
before(async () => {
await esArchiver.load('x-pack/test/functional/es_archives/auditbeat/hosts');
});
after(async () => {
await esArchiver.unload('x-pack/test/functional/es_archives/auditbeat/hosts');
});
beforeEach(async () => {
await deleteAllAlerts(supertest);
await createSignalsIndex(supertest);
});
afterEach(async () => {
await deleteSignalsIndex(supertest);
await deleteAllAlerts(supertest);
});
it('should be able to execute and get 10 signals', async () => {
const rule = getRuleForSignalTesting(['auditbeat-*']);
const { id } = await createRule(supertest, rule);
await waitForRuleSuccessOrStatus(supertest, id);
await waitForSignalsToBePresent(supertest, 10, [id]);
const signalsOpen = await getSignalsByIds(supertest, [id]);
expect(signalsOpen.hits.hits.length).equal(10);
});
it('should be have set the signals in an open state initially', async () => {
const rule = getRuleForSignalTesting(['auditbeat-*']);
const { id } = await createRule(supertest, rule);
await waitForRuleSuccessOrStatus(supertest, id);
await waitForSignalsToBePresent(supertest, 10, [id]);
const signalsOpen = await getSignalsByIds(supertest, [id]);
const everySignalOpen = signalsOpen.hits.hits.every(
(hit) => hit._source?.signal?.status === 'open'
);
expect(everySignalOpen).to.eql(true);
});
it('should be able to get a count of 10 closed signals when closing 10', async () => {
const rule = getRuleForSignalTesting(['auditbeat-*']);
const { id } = await createRule(supertest, rule);
await waitForRuleSuccessOrStatus(supertest, id);
await waitForSignalsToBePresent(supertest, 10, [id]);
const signalsOpen = await getSignalsByIds(supertest, [id]);
const signalIds = signalsOpen.hits.hits.map((signal) => signal._id);
// set all of the signals to the state of closed. There is no reason to use a waitUntil here
// as this route intentionally has a waitFor within it and should only return when the query has
// the data.
await supertest
.post(RAC_ALERTS_BULK_UPDATE_URL)
.set('kbn-xsrf', 'true')
.send({ ids: signalIds, status: 'closed', index: '.siem-signals-default' })
.expect(200);
const {
body: signalsClosed,
}: { body: estypes.SearchResponse<{ signal: Signal }> } = await supertest
.post(DETECTION_ENGINE_QUERY_SIGNALS_URL)
.set('kbn-xsrf', 'true')
.send(getQuerySignalIds(signalIds))
.expect(200);
expect(signalsClosed.hits.hits.length).to.equal(10);
});
it('should be able close 10 signals immediately and they all should be closed', async () => {
const rule = getRuleForSignalTesting(['auditbeat-*']);
const { id } = await createRule(supertest, rule);
await waitForRuleSuccessOrStatus(supertest, id);
await waitForSignalsToBePresent(supertest, 10, [id]);
const signalsOpen = await getSignalsByIds(supertest, [id]);
const signalIds = signalsOpen.hits.hits.map((signal) => signal._id);
// set all of the signals to the state of closed. There is no reason to use a waitUntil here
// as this route intentionally has a waitFor within it and should only return when the query has
// the data.
await supertest
.post(RAC_ALERTS_BULK_UPDATE_URL)
.set('kbn-xsrf', 'true')
.send({ ids: signalIds, status: 'closed', index: '.siem-signals-default' })
.expect(200);
const {
body: signalsClosed,
}: { body: estypes.SearchResponse<{ signal: Signal }> } = await supertest
.post(DETECTION_ENGINE_QUERY_SIGNALS_URL)
.set('kbn-xsrf', 'true')
.send(getQuerySignalIds(signalIds))
.expect(200);
const everySignalClosed = signalsClosed.hits.hits.every(
(hit) => hit._source?.signal?.status === 'closed'
);
expect(everySignalClosed).to.eql(true);
});
});
});
};

View file

@ -9,7 +9,7 @@
"kibana.alert.rule.rule_type_id": "apm.error_rate",
"message": "hello world 1",
"kibana.alert.rule.consumer": "apm",
"kibana.alert.status": "open",
"kibana.alert.workflow_status": "open",
"kibana.space_ids": ["space1", "space2"]
}
}
@ -26,7 +26,7 @@
"kibana.alert.rule.rule_type_id": "apm.error_rate",
"message": "hello world 1",
"kibana.alert.rule.consumer": "apm",
"kibana.alert.status": "open",
"kibana.alert.workflow_status": "open",
"kibana.space_ids": ["space1"]
}
}
@ -43,7 +43,7 @@
"kibana.alert.rule.rule_type_id": "apm.error_rate",
"message": "hello world 1",
"kibana.alert.rule.consumer": "apm",
"kibana.alert.status": "open",
"kibana.alert.workflow_status": "open",
"kibana.space_ids": ["space2"]
}
}
@ -60,7 +60,7 @@
"kibana.alert.rule.rule_type_id": "siem.signals",
"message": "hello world security",
"kibana.alert.rule.consumer": "siem",
"kibana.alert.status": "open",
"kibana.alert.workflow_status": "open",
"kibana.space_ids": ["space1", "space2"]
}
}
@ -77,7 +77,7 @@
"kibana.alert.rule.rule_type_id": "siem.customRule",
"message": "hello world security",
"kibana.alert.rule.consumer": "siem",
"kibana.alert.status": "open",
"kibana.alert.workflow_status": "open",
"kibana.space_ids": ["space1", "space2"]
}
}
@ -93,7 +93,7 @@
"kibana.alert.rule.rule_type_id": "siem.signals",
"message": "hello world security",
"kibana.alert.rule.consumer": "siem",
"kibana.alert.status": "open",
"kibana.alert.workflow_status": "open",
"kibana.space_ids": ["space1"]
}
}
@ -109,7 +109,7 @@
"kibana.alert.rule.rule_type_id": "siem.signals",
"message": "hello world security",
"kibana.alert.rule.consumer": "siem",
"kibana.alert.status": "open",
"kibana.alert.workflow_status": "open",
"kibana.space_ids": ["space2"]
}
}

View file

@ -6,6 +6,7 @@
*/
import expect from '@kbn/expect';
import { ALERT_WORKFLOW_STATUS } from '@kbn/rule-data-utils';
import {
superUser,
globalRead,
@ -26,6 +27,7 @@ import {
secOnlySpacesAll,
noKibanaPrivileges,
} from '../../../common/lib/authentication/users';
import type { User } from '../../../common/lib/authentication/types';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
import { getSpaceUrlPrefix } from '../../../common/lib/authentication/spaces';
@ -56,7 +58,6 @@ export default ({ getService }: FtrProviderContext) => {
const APM_ALERT_INDEX = '.alerts-observability-apm';
const SECURITY_SOLUTION_ALERT_ID = '020202';
const SECURITY_SOLUTION_ALERT_INDEX = '.alerts-security.alerts';
// const ALERT_VERSION = Buffer.from(JSON.stringify([0, 1]), 'utf8').toString('base64'); // required for optimistic concurrency control
const getAPMIndexName = async (user: User) => {
const {
@ -119,14 +120,30 @@ export default ({ getService }: FtrProviderContext) => {
items.map((item) => expect(item.update.result).to.eql('updated'));
});
it(`${username} should bulk update alerts which match query in ${space}/${index}`, async () => {
it(`${username} should bulk update alerts which match KQL query string in ${space}/${index}`, async () => {
await esArchiver.load('x-pack/test/functional/es_archives/rule_registry/alerts'); // since this is a success case, reload the test data immediately beforehand
const { body: updated } = await supertestWithoutAuth
.post(`${getSpaceUrlPrefix(space)}${TEST_URL}/bulk_update`)
.auth(username, password)
.set('kbn-xsrf', 'true')
.send({
status: 'closed',
query: 'kibana.alert.status: open',
query: `${ALERT_WORKFLOW_STATUS}: open`,
index,
});
expect(updated.statusCode).to.eql(200);
expect(updated.body.updated).to.greaterThan(0);
});
it(`${username} should bulk update alerts which match query in DSL in ${space}/${index}`, async () => {
await esArchiver.load('x-pack/test/functional/es_archives/rule_registry/alerts'); // since this is a success case, reload the test data immediately beforehand
const { body: updated } = await supertestWithoutAuth
.post(`${getSpaceUrlPrefix(space)}${TEST_URL}/bulk_update`)
.auth(username, password)
.set('kbn-xsrf', 'true')
.send({
status: 'closed',
query: { match: { [ALERT_WORKFLOW_STATUS]: 'open' } },
index,
});
expect(updated.statusCode).to.eql(200);