diff --git a/packages/kbn-alerts-as-data-utils/src/field_maps/alert_field_map.ts b/packages/kbn-alerts-as-data-utils/src/field_maps/alert_field_map.ts index 8e08439e7450a..07ada8b7c06b5 100644 --- a/packages/kbn-alerts-as-data-utils/src/field_maps/alert_field_map.ts +++ b/packages/kbn-alerts-as-data-utils/src/field_maps/alert_field_map.ts @@ -32,6 +32,7 @@ import { ALERT_TIME_RANGE, ALERT_URL, ALERT_UUID, + ALERT_WORKFLOW_ASSIGNEE_IDS, ALERT_WORKFLOW_STATUS, ALERT_WORKFLOW_TAGS, SPACE_IDS, @@ -190,6 +191,11 @@ export const alertFieldMap = { array: true, required: false, }, + [ALERT_WORKFLOW_ASSIGNEE_IDS]: { + type: 'keyword', + array: true, + required: false, + }, [EVENT_ACTION]: { type: 'keyword', array: false, diff --git a/packages/kbn-alerts-as-data-utils/src/schemas/generated/alert_schema.ts b/packages/kbn-alerts-as-data-utils/src/schemas/generated/alert_schema.ts index ac143adf5f5d5..5625460f269b4 100644 --- a/packages/kbn-alerts-as-data-utils/src/schemas/generated/alert_schema.ts +++ b/packages/kbn-alerts-as-data-utils/src/schemas/generated/alert_schema.ts @@ -98,6 +98,7 @@ const AlertOptional = rt.partial({ 'kibana.alert.start': schemaDate, 'kibana.alert.time_range': schemaDateRange, 'kibana.alert.url': schemaString, + 'kibana.alert.workflow_assignee_ids': schemaStringArray, 'kibana.alert.workflow_status': schemaString, 'kibana.alert.workflow_tags': schemaStringArray, 'kibana.version': schemaString, diff --git a/packages/kbn-alerts-as-data-utils/src/schemas/generated/security_schema.ts b/packages/kbn-alerts-as-data-utils/src/schemas/generated/security_schema.ts index f8648bfa4218d..a0af087b70c9f 100644 --- a/packages/kbn-alerts-as-data-utils/src/schemas/generated/security_schema.ts +++ b/packages/kbn-alerts-as-data-utils/src/schemas/generated/security_schema.ts @@ -193,6 +193,7 @@ const SecurityAlertOptional = rt.partial({ ), 'kibana.alert.time_range': schemaDateRange, 'kibana.alert.url': schemaString, + 'kibana.alert.workflow_assignee_ids': schemaStringArray, 'kibana.alert.workflow_reason': schemaString, 'kibana.alert.workflow_status': schemaString, 'kibana.alert.workflow_tags': schemaStringArray, diff --git a/packages/kbn-alerts-as-data-utils/src/search/security/fields.ts b/packages/kbn-alerts-as-data-utils/src/search/security/fields.ts index b3be5cbb62a1a..34da32b0eaa5a 100644 --- a/packages/kbn-alerts-as-data-utils/src/search/security/fields.ts +++ b/packages/kbn-alerts-as-data-utils/src/search/security/fields.ts @@ -11,6 +11,7 @@ import { ALERT_RISK_SCORE, ALERT_SEVERITY, ALERT_RULE_PARAMETERS, + ALERT_WORKFLOW_ASSIGNEE_IDS, ALERT_WORKFLOW_TAGS, } from '@kbn/rule-data-utils'; @@ -46,6 +47,7 @@ export const ALERT_EVENTS_FIELDS = [ ALERT_RULE_CONSUMER, '@timestamp', 'kibana.alert.ancestors.index', + ALERT_WORKFLOW_ASSIGNEE_IDS, 'kibana.alert.workflow_status', ALERT_WORKFLOW_TAGS, 'kibana.alert.group.id', diff --git a/packages/kbn-rule-data-utils/src/default_alerts_as_data.ts b/packages/kbn-rule-data-utils/src/default_alerts_as_data.ts index d1aec24a9b26e..7c08271478131 100644 --- a/packages/kbn-rule-data-utils/src/default_alerts_as_data.ts +++ b/packages/kbn-rule-data-utils/src/default_alerts_as_data.ts @@ -70,6 +70,9 @@ const ALERT_WORKFLOW_STATUS = `${ALERT_NAMESPACE}.workflow_status` as const; // kibana.alert.workflow_tags - user workflow alert tags const ALERT_WORKFLOW_TAGS = `${ALERT_NAMESPACE}.workflow_tags` as const; +// kibana.alert.workflow_assignee_ids - user workflow alert assignees +const ALERT_WORKFLOW_ASSIGNEE_IDS = `${ALERT_NAMESPACE}.workflow_assignee_ids` as const; + // kibana.alert.rule.category - rule type name for rule that generated this alert const ALERT_RULE_CATEGORY = `${ALERT_RULE_NAMESPACE}.category` as const; @@ -135,6 +138,7 @@ const fields = { ALERT_TIME_RANGE, ALERT_URL, ALERT_UUID, + ALERT_WORKFLOW_ASSIGNEE_IDS, ALERT_WORKFLOW_STATUS, ALERT_WORKFLOW_TAGS, SPACE_IDS, @@ -174,6 +178,7 @@ export { ALERT_TIME_RANGE, ALERT_URL, ALERT_UUID, + ALERT_WORKFLOW_ASSIGNEE_IDS, ALERT_WORKFLOW_STATUS, ALERT_WORKFLOW_TAGS, SPACE_IDS, diff --git a/packages/kbn-rule-data-utils/src/technical_field_names.ts b/packages/kbn-rule-data-utils/src/technical_field_names.ts index b387ab67750d5..951766977f05e 100644 --- a/packages/kbn-rule-data-utils/src/technical_field_names.ts +++ b/packages/kbn-rule-data-utils/src/technical_field_names.ts @@ -32,6 +32,7 @@ import { ALERT_STATUS, ALERT_TIME_RANGE, ALERT_UUID, + ALERT_WORKFLOW_ASSIGNEE_IDS, ALERT_WORKFLOW_STATUS, ALERT_WORKFLOW_TAGS, SPACE_IDS, @@ -174,6 +175,7 @@ const fields = { ALERT_STATUS, ALERT_SYSTEM_STATUS, ALERT_UUID, + ALERT_WORKFLOW_ASSIGNEE_IDS, ALERT_WORKFLOW_REASON, ALERT_WORKFLOW_STATUS, ALERT_WORKFLOW_TAGS, diff --git a/packages/kbn-securitysolution-ecs/src/signal/index.ts b/packages/kbn-securitysolution-ecs/src/signal/index.ts index 679ab70264d26..623d6a3e96a64 100644 --- a/packages/kbn-securitysolution-ecs/src/signal/index.ts +++ b/packages/kbn-securitysolution-ecs/src/signal/index.ts @@ -24,6 +24,7 @@ export type SignalEcsAAD = Exclude & { building_block_type?: string[]; workflow_status?: string[]; workflow_tags?: string[]; + workflow_assignee_ids?: string[]; suppression?: { docs_count: string[]; }; diff --git a/x-pack/packages/security-solution/upselling/messages/index.tsx b/x-pack/packages/security-solution/upselling/messages/index.tsx index 633f44d21b770..4933ff36cfa11 100644 --- a/x-pack/packages/security-solution/upselling/messages/index.tsx +++ b/x-pack/packages/security-solution/upselling/messages/index.tsx @@ -14,3 +14,11 @@ export const UPGRADE_INVESTIGATION_GUIDE = (requiredLicense: string) => requiredLicense, }, }); + +export const UPGRADE_ALERT_ASSIGNMENTS = (requiredLicense: string) => + i18n.translate('securitySolutionPackages.alertAssignments.upsell', { + defaultMessage: 'Upgrade to {requiredLicense} to make use of alert assignments', + values: { + requiredLicense, + }, + }); diff --git a/x-pack/packages/security-solution/upselling/service/types.ts b/x-pack/packages/security-solution/upselling/service/types.ts index d14f39ac9796a..fdb66b27d97f4 100644 --- a/x-pack/packages/security-solution/upselling/service/types.ts +++ b/x-pack/packages/security-solution/upselling/service/types.ts @@ -17,4 +17,4 @@ export type UpsellingSectionId = | 'osquery_automated_response_actions' | 'ruleDetailsEndpointExceptions'; -export type UpsellingMessageId = 'investigation_guide'; +export type UpsellingMessageId = 'investigation_guide' | 'alert_assignments'; diff --git a/x-pack/plugins/alerting/common/alert_schema/field_maps/mapping_from_field_map.test.ts b/x-pack/plugins/alerting/common/alert_schema/field_maps/mapping_from_field_map.test.ts index eb3132eba5413..24aa552f6a1ef 100644 --- a/x-pack/plugins/alerting/common/alert_schema/field_maps/mapping_from_field_map.test.ts +++ b/x-pack/plugins/alerting/common/alert_schema/field_maps/mapping_from_field_map.test.ts @@ -311,6 +311,9 @@ describe('mappingFromFieldMap', () => { workflow_tags: { type: 'keyword', }, + workflow_assignee_ids: { + type: 'keyword', + }, }, }, space_ids: { diff --git a/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.test.ts b/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.test.ts index be24975deae6f..e79caa6393975 100644 --- a/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.test.ts +++ b/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.test.ts @@ -293,6 +293,11 @@ it('matches snapshot', () => { "required": true, "type": "keyword", }, + "kibana.alert.workflow_assignee_ids": Object { + "array": true, + "required": false, + "type": "keyword", + }, "kibana.alert.workflow_reason": Object { "array": false, "required": false, diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/index.ts b/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/index.ts new file mode 100644 index 0000000000000..b74132faed031 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/index.ts @@ -0,0 +1,8 @@ +/* + * 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 * from './set_alert_assignees_route.gen'; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/mocks.ts b/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/mocks.ts new file mode 100644 index 0000000000000..ef668dc36d421 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/mocks.ts @@ -0,0 +1,8 @@ +/* + * 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 * from './set_alert_assignees_route.mock'; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/set_alert_assignees_route.gen.ts b/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/set_alert_assignees_route.gen.ts new file mode 100644 index 0000000000000..f2b2be478ced3 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/set_alert_assignees_route.gen.ts @@ -0,0 +1,46 @@ +/* + * 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 { z } from 'zod'; + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + */ + +import { NonEmptyString } from '../model/rule_schema/common_attributes.gen'; + +export type AlertAssignees = z.infer; +export const AlertAssignees = z.object({ + /** + * A list of users ids to assign. + */ + add: z.array(NonEmptyString), + /** + * A list of users ids to unassign. + */ + remove: z.array(NonEmptyString), +}); + +/** + * A list of alerts ids. + */ +export type AlertIds = z.infer; +export const AlertIds = z.array(NonEmptyString).min(1); + +export type SetAlertAssigneesRequestBody = z.infer; +export const SetAlertAssigneesRequestBody = z.object({ + /** + * Details about the assignees to assign and unassign. + */ + assignees: AlertAssignees, + /** + * List of alerts ids to assign and unassign passed assignees. + */ + ids: AlertIds, +}); +export type SetAlertAssigneesRequestBodyInput = z.input; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/set_alert_assignees_route.mock.ts b/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/set_alert_assignees_route.mock.ts new file mode 100644 index 0000000000000..9c41e2eae8058 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/set_alert_assignees_route.mock.ts @@ -0,0 +1,17 @@ +/* + * 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 { SetAlertAssigneesRequestBody } from './set_alert_assignees_route.gen'; + +export const getSetAlertAssigneesRequestMock = ( + assigneesToAdd: string[] = [], + assigneesToRemove: string[] = [], + ids: string[] = [] +): SetAlertAssigneesRequestBody => ({ + assignees: { add: assigneesToAdd, remove: assigneesToRemove }, + ids, +}); diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/set_alert_assignees_route.schema.yaml b/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/set_alert_assignees_route.schema.yaml new file mode 100644 index 0000000000000..6c3663402118a --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/set_alert_assignees_route.schema.yaml @@ -0,0 +1,58 @@ +openapi: 3.0.0 +info: + title: Assign alerts API endpoint + version: '2023-10-31' +paths: + /api/detection_engine/signals/assignees: + summary: Assigns users to alerts + post: + operationId: SetAlertAssignees + x-codegen-enabled: true + description: Assigns users to alerts. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - assignees + - ids + properties: + assignees: + $ref: '#/components/schemas/AlertAssignees' + description: Details about the assignees to assign and unassign. + ids: + $ref: '#/components/schemas/AlertIds' + description: List of alerts ids to assign and unassign passed assignees. + responses: + 200: + description: Indicates a successful call. + 400: + description: Invalid request. + +components: + schemas: + AlertAssignees: + type: object + required: + - add + - remove + properties: + add: + type: array + items: + $ref: '../model/rule_schema/common_attributes.schema.yaml#/components/schemas/NonEmptyString' + description: A list of users ids to assign. + remove: + type: array + items: + $ref: '../model/rule_schema/common_attributes.schema.yaml#/components/schemas/NonEmptyString' + description: A list of users ids to unassign. + + AlertIds: + type: array + items: + $ref: '../model/rule_schema/common_attributes.schema.yaml#/components/schemas/NonEmptyString' + minItems: 1 + description: A list of alerts ids. diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/index.ts b/x-pack/plugins/security_solution/common/api/detection_engine/index.ts index eadf1e48e9e31..56c6d4225f745 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/index.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/index.ts @@ -5,6 +5,7 @@ * 2.0. */ +export * from './alert_assignees'; export * from './alert_tags'; export * from './fleet_integrations'; export * from './index_management'; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/model/alerts/8.12.0/index.ts b/x-pack/plugins/security_solution/common/api/detection_engine/model/alerts/8.12.0/index.ts new file mode 100644 index 0000000000000..da97667035a66 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/model/alerts/8.12.0/index.ts @@ -0,0 +1,56 @@ +/* + * 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 { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils'; +import type { AlertWithCommonFields800 } from '@kbn/rule-registry-plugin/common/schemas/8.0.0'; +import type { + Ancestor890, + BaseFields890, + EqlBuildingBlockFields890, + EqlShellFields890, + NewTermsFields890, +} from '../8.9.0'; + +/* DO NOT MODIFY THIS SCHEMA TO ADD NEW FIELDS. These types represent the alerts that shipped in 8.12.0. +Any changes to these types should be bug fixes so the types more accurately represent the alerts from 8.12.0. +If you are adding new fields for a new release of Kibana, create a new sibling folder to this one +for the version to be released and add the field(s) to the schema in that folder. +Then, update `../index.ts` to import from the new folder that has the latest schemas, add the +new schemas to the union of all alert schemas, and re-export the new schemas as the `*Latest` schemas. +*/ + +export type { Ancestor890 as Ancestor8120 }; + +export interface BaseFields8120 extends BaseFields890 { + [ALERT_WORKFLOW_ASSIGNEE_IDS]: string[] | undefined; +} + +export interface WrappedFields8120 { + _id: string; + _index: string; + _source: T; +} + +export type GenericAlert8120 = AlertWithCommonFields800; + +export type EqlShellFields8120 = EqlShellFields890 & BaseFields8120; + +export type EqlBuildingBlockFields8120 = EqlBuildingBlockFields890 & BaseFields8120; + +export type NewTermsFields8120 = NewTermsFields890 & BaseFields8120; + +export type NewTermsAlert8120 = NewTermsFields890 & BaseFields8120; + +export type EqlBuildingBlockAlert8120 = AlertWithCommonFields800; + +export type EqlShellAlert8120 = AlertWithCommonFields800; + +export type DetectionAlert8120 = + | GenericAlert8120 + | EqlShellAlert8120 + | EqlBuildingBlockAlert8120 + | NewTermsAlert8120; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/model/alerts/index.ts b/x-pack/plugins/security_solution/common/api/detection_engine/model/alerts/index.ts index d3718c4f07db9..742e5fd4ecfc1 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/model/alerts/index.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/model/alerts/index.ts @@ -11,15 +11,16 @@ import type { DetectionAlert840 } from './8.4.0'; import type { DetectionAlert860 } from './8.6.0'; import type { DetectionAlert870 } from './8.7.0'; import type { DetectionAlert880 } from './8.8.0'; +import type { DetectionAlert890 } from './8.9.0'; import type { - Ancestor890, - BaseFields890, - DetectionAlert890, - EqlBuildingBlockFields890, - EqlShellFields890, - NewTermsFields890, - WrappedFields890, -} from './8.9.0'; + Ancestor8120, + BaseFields8120, + DetectionAlert8120, + EqlBuildingBlockFields8120, + EqlShellFields8120, + NewTermsFields8120, + WrappedFields8120, +} from './8.12.0'; // When new Alert schemas are created for new Kibana versions, add the DetectionAlert type from the new version // here, e.g. `export type DetectionAlert = DetectionAlert800 | DetectionAlert820` if a new schema is created in 8.2.0 @@ -29,14 +30,15 @@ export type DetectionAlert = | DetectionAlert860 | DetectionAlert870 | DetectionAlert880 - | DetectionAlert890; + | DetectionAlert890 + | DetectionAlert8120; export type { - Ancestor890 as AncestorLatest, - BaseFields890 as BaseFieldsLatest, - DetectionAlert890 as DetectionAlertLatest, - WrappedFields890 as WrappedFieldsLatest, - EqlBuildingBlockFields890 as EqlBuildingBlockFieldsLatest, - EqlShellFields890 as EqlShellFieldsLatest, - NewTermsFields890 as NewTermsFieldsLatest, + Ancestor8120 as AncestorLatest, + BaseFields8120 as BaseFieldsLatest, + DetectionAlert8120 as DetectionAlertLatest, + WrappedFields8120 as WrappedFieldsLatest, + EqlBuildingBlockFields8120 as EqlBuildingBlockFieldsLatest, + EqlShellFields8120 as EqlShellFieldsLatest, + NewTermsFields8120 as NewTermsFieldsLatest, }; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/model/schemas.ts b/x-pack/plugins/security_solution/common/api/detection_engine/model/schemas.ts index bfbba49bb80ea..44d3023739446 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/model/schemas.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/model/schemas.ts @@ -107,3 +107,6 @@ export const alert_tags = t.type({ }); export type AlertTags = t.TypeOf; + +export const user_search_term = t.string; +export type UserSearchTerm = t.TypeOf; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/users/index.ts b/x-pack/plugins/security_solution/common/api/detection_engine/users/index.ts new file mode 100644 index 0000000000000..b4775b77bf69f --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/users/index.ts @@ -0,0 +1,8 @@ +/* + * 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 * from './suggest_user_profiles_route.gen'; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/users/suggest_user_profiles_route.gen.ts b/x-pack/plugins/security_solution/common/api/detection_engine/users/suggest_user_profiles_route.gen.ts new file mode 100644 index 0000000000000..f403501c52ea7 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/users/suggest_user_profiles_route.gen.ts @@ -0,0 +1,22 @@ +/* + * 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 { z } from 'zod'; + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + */ + +export type SuggestUserProfilesRequestQuery = z.infer; +export const SuggestUserProfilesRequestQuery = z.object({ + /** + * Query string used to match name-related fields in user profiles. The following fields are treated as name-related: username, full_name and email + */ + searchTerm: z.string().optional(), +}); +export type SuggestUserProfilesRequestQueryInput = z.input; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/users/suggest_user_profiles_route.schema.yaml b/x-pack/plugins/security_solution/common/api/detection_engine/users/suggest_user_profiles_route.schema.yaml new file mode 100644 index 0000000000000..babaedf1486ff --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/users/suggest_user_profiles_route.schema.yaml @@ -0,0 +1,23 @@ +openapi: 3.0.0 +info: + title: Suggest user profiles API endpoint + version: '2023-10-31' +paths: + /api/detection_engine/signals/_find: + summary: Suggests user profiles based on provided search term + post: + operationId: SuggestUserProfiles + x-codegen-enabled: true + description: Suggests user profiles. + parameters: + - name: searchTerm + in: query + required: false + description: "Query string used to match name-related fields in user profiles. The following fields are treated as name-related: username, full_name and email" + schema: + type: string + responses: + 200: + description: Indicates a successful call. + 400: + description: Invalid request. diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index e50533f223928..655df5eec753c 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -319,6 +319,10 @@ export const DETECTION_ENGINE_SIGNALS_MIGRATION_STATUS_URL = export const DETECTION_ENGINE_SIGNALS_FINALIZE_MIGRATION_URL = `${DETECTION_ENGINE_SIGNALS_URL}/finalize_migration` as const; export const DETECTION_ENGINE_ALERT_TAGS_URL = `${DETECTION_ENGINE_SIGNALS_URL}/tags` as const; +export const DETECTION_ENGINE_ALERT_ASSIGNEES_URL = + `${DETECTION_ENGINE_SIGNALS_URL}/assignees` as const; +export const DETECTION_ENGINE_ALERT_SUGGEST_USERS_URL = + `${DETECTION_ENGINE_SIGNALS_URL}/_find` as const; export const ALERTS_AS_DATA_URL = '/internal/rac/alerts' as const; export const ALERTS_AS_DATA_FIND_URL = `${ALERTS_AS_DATA_URL}/find` as const; diff --git a/x-pack/plugins/security_solution/common/types/timeline/cells/index.ts b/x-pack/plugins/security_solution/common/types/timeline/cells/index.ts index 134b659116ee0..8435e6ec89845 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/cells/index.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/cells/index.ts @@ -8,6 +8,7 @@ import type { EuiDataGridCellValueElementProps } from '@elastic/eui'; import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; import type { ColumnHeaderOptions, RowRenderer } from '../..'; +import type { RenderCellValueContext } from '../../../../public/detections/configurations/security_solution_detections/fetch_page_context'; import type { BrowserFields, TimelineNonEcsData } from '../../../search_strategy'; /** The following props are provided to the function called by `renderCellValue` */ @@ -28,4 +29,5 @@ export type CellValueElementProps = EuiDataGridCellValueElementProps & { truncate?: boolean; key?: string; closeCellPopover?: () => void; + context?: RenderCellValueContext; }; diff --git a/x-pack/plugins/security_solution/docs/testing/test_plans/detection_response/alerts/user_assignment.md b/x-pack/plugins/security_solution/docs/testing/test_plans/detection_response/alerts/user_assignment.md new file mode 100644 index 0000000000000..a2b360423e5c8 --- /dev/null +++ b/x-pack/plugins/security_solution/docs/testing/test_plans/detection_response/alerts/user_assignment.md @@ -0,0 +1,216 @@ +# Alert User Assignment + +This is a test plan for the Alert User Assignment feature + +Status: `in progress`. The current test plan covers functionality described in [Alert User Assignment](https://github.com/elastic/security-team/issues/2504) epic. + +## Useful information + +### Tickets + +- [Alert User Assignment](https://github.com/elastic/security-team/issues/2504) epic +- [Add test coverage for Alert User Assignment](https://github.com/elastic/kibana/issues/171307) +- [Write a test plan for Alert User Assignment](https://github.com/elastic/kibana/issues/171306) + +### Terminology + +- **Assignee**: The user assigned to an alert. + +- **Assignees field**: The alert's `kibana.alert.workflow_assignee_ids` field which contains an array of assignees IDs. These ids conrespond to [User Profiles](https://www.elastic.co/guide/en/elasticsearch/reference/current/user-profile.html) endpoint. + +- **Assignee's avatar**: The avatar of an assignee. Can be either user profile picture if uploaded by the user or initials of the user. + +- **Assignees count badge**: The badge with the number of assignees. + +### Assumptions + +- The feature is **NOT** available under the Basic license +- Assignees are stored as an array of users IDs in alert's `kibana.alert.workflow_assignee_ids` field +- There are multiple (five or more) available users which could be assigned to alerts +- User need to have editor or higher privileges to assign users to alerts +- Mixed states are not supported by the current version of User Profiles component +- "Displayed/Shown in UI" refers to "Alerts Table" and "Alert's Details Flyout" + +## Scenarios + +### Basic rendering + +#### **Scenario: No assignees** + +**Automation**: 2 e2e test + 2 unit test. + +```Gherkin +Given an alert doesn't have assignees +Then no assignees' (represented by avatars) should be displayed in UI +``` + +#### **Scenario: With assignees** + +**Automation**: 2 e2e test + 2 unit test. + +```Gherkin +Given an alert has assignees +Then assignees' (represented by avatars) for each assignee should be shown in UI +``` + +#### **Scenario: Many assignees (Badge)** + +**Automation**: 2 e2e test + 2 unit test. + +```Gherkin +Given an alert has more assignees than maximum number allowed to display +Then assignees count badge is displayed in UI +``` + +### Updating assignees (single alert) + +#### **Scenario: Add new assignees** + +**Automation**: 3 e2e test + 1 unit test + 1 integration test. + +```Gherkin +Given an alert +When user adds new assignees +Then assignees field should be updated +And newly added assignees should be present +``` + +#### **Scenario: Update assignees** + +**Automation**: 3 e2e test + 1 unit test + 1 integration test. + +```Gherkin +Given an alert with assignees +When user removes some of (or all) current assignees and adds new assignees +Then assignees field should be updated +And removed assignees should be absent +And newly added assignees should be present +``` + +#### **Scenario: Unassign alert** + +**Automation**: 2 e2e test + 1 unit test. + +```Gherkin +Given an alert with assignees +When user triggers "Unassign alert" action +Then assignees field should be updated +And assignees field should be empty +``` + +### Updating assignees (bulk actions) + +#### **Scenario: Add new assignees** + +**Automation**: 1 e2e test + 1 unit test + 1 integration test. + +```Gherkin +Given multiple alerts +When user adds new assignees +Then assignees fields of all involved alerts should be updated +And newly added assignees should be present +``` + +#### **Scenario: Update assignees** + +**Automation**: 1 e2e test + 1 unit test + 1 integration test. + +```Gherkin +Given multiple alerts with assignees +When user removes some of (or all) current assignees and adds new assignees +Then assignees fields of all involved alerts should be updated +And removed assignees should be absent +And newly added assignees should be present +``` + +#### **Scenario: Unassign alert** + +**Automation**: 1 e2e test + 1 unit test. + +```Gherkin +Given multiple alerts with assignees +When user triggers "Unassign alert" action +Then assignees fields of all involved alerts should be updated +And assignees fields should be empty +``` + +### Alerts filtering + +#### **Scenario: By one assignee** + +**Automation**: 1 e2e test + 1 unit test. + +```Gherkin +Given multiple alerts with and without assignees +When user filters by one of the assignees +Then only alerts with selected assignee in assignees field are displayed +``` + +#### **Scenario: By multiple assignees** + +**Automation**: 1 e2e test + 1 unit test. + +```Gherkin +Given multiple alerts with and without assignees +When user filters by multiple assignees +Then all alerts with either of selected assignees in assignees fields are displayed +``` + +#### **Scenario: "No assignees" option** + +**Automation**: 1 e2e test + 1 unit test. + +```Gherkin +Given filter by assignees UI is available +Then there should be an option to filter alerts to see those which are not assigned to anyone +``` + +#### **Scenario: By "No assignees"** + +**Automation**: 1 e2e test + 1 unit test. + +```Gherkin +Given multiple alerts with and without assignees +When user filters by "No assignees" option +Then all alerts with empty assignees fields are displayed +``` + +#### **Scenario: By assignee and alert status** + +**Automation**: 1 e2e test + 1 unit test. + +```Gherkin +Given multiple alerts with and without assignees +When user filters by one of the assignees +AND alert's status +Then only alerts with selected assignee in assignees field AND selected alert's status are displayed +``` + +### Authorization / RBAC + +#### **Scenario: Viewer role** + +**Automation**: 1 e2e test + 1 unit test + 1 integration test. + +```Gherkin +Given user has "viewer/readonly" role +Then there should not be a way to update assignees field for an alert +``` + +#### **Scenario: Serverless roles** + +**Automation**: 1 e2e test + 1 unit test + 1 integration test. + +```Gherkin +Given users 't1_analyst', 't2_analyst', 't3_analyst', 'rule_author', 'soc_manager', 'detections_admin', 'platform_engineer' roles +Then update assignees functionality should be available +``` + +#### **Scenario: Basic license** + +**Automation**: 1 e2e test + 1 unit test + 1 integration test. + +```Gherkin +Given user runs Kibana under the Basic license +Then update assignees functionality should not be available +``` diff --git a/x-pack/plugins/security_solution/public/common/components/assignees/assignees_apply_panel.test.tsx b/x-pack/plugins/security_solution/public/common/components/assignees/assignees_apply_panel.test.tsx new file mode 100644 index 0000000000000..e9054a6817e14 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/assignees/assignees_apply_panel.test.tsx @@ -0,0 +1,139 @@ +/* + * 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 { render } from '@testing-library/react'; + +import { ASSIGNEES_APPLY_BUTTON_TEST_ID, ASSIGNEES_APPLY_PANEL_TEST_ID } from './test_ids'; +import { AssigneesApplyPanel } from './assignees_apply_panel'; + +import { useGetCurrentUserProfile } from '../user_profiles/use_get_current_user_profile'; +import { useBulkGetUserProfiles } from '../user_profiles/use_bulk_get_user_profiles'; +import { useSuggestUsers } from '../user_profiles/use_suggest_users'; +import { TestProviders } from '../../mock'; +import * as i18n from './translations'; +import { mockUserProfiles } from './mocks'; + +jest.mock('../user_profiles/use_get_current_user_profile'); +jest.mock('../user_profiles/use_bulk_get_user_profiles'); +jest.mock('../user_profiles/use_suggest_users'); + +const renderAssigneesApplyPanel = ( + { + assignedUserIds, + showUnassignedOption, + onSelectionChange, + onAssigneesApply, + }: { + assignedUserIds: string[]; + showUnassignedOption?: boolean; + onSelectionChange?: () => void; + onAssigneesApply?: () => void; + } = { assignedUserIds: [] } +) => { + const assignedProfiles = mockUserProfiles.filter((user) => assignedUserIds.includes(user.uid)); + (useBulkGetUserProfiles as jest.Mock).mockReturnValue({ + isLoading: false, + data: assignedProfiles, + }); + return render( + + + + ); +}; + +describe('', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useGetCurrentUserProfile as jest.Mock).mockReturnValue({ + isLoading: false, + data: mockUserProfiles[0], + }); + (useSuggestUsers as jest.Mock).mockReturnValue({ + isLoading: false, + data: mockUserProfiles, + }); + }); + + it('should render component', () => { + const { getByTestId, queryByTestId } = renderAssigneesApplyPanel(); + + expect(getByTestId(ASSIGNEES_APPLY_PANEL_TEST_ID)).toBeInTheDocument(); + expect(queryByTestId(ASSIGNEES_APPLY_BUTTON_TEST_ID)).not.toBeInTheDocument(); + }); + + it('should render apply button if `onAssigneesApply` callback provided', () => { + const { getByTestId } = renderAssigneesApplyPanel({ + assignedUserIds: [], + onAssigneesApply: jest.fn(), + }); + + expect(getByTestId(ASSIGNEES_APPLY_PANEL_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(ASSIGNEES_APPLY_BUTTON_TEST_ID)).toBeInTheDocument(); + }); + + it('should render `no assignees` option', () => { + const { getByTestId } = renderAssigneesApplyPanel({ + assignedUserIds: [], + showUnassignedOption: true, + onAssigneesApply: jest.fn(), + }); + + const assigneesList = getByTestId('euiSelectableList'); + expect(assigneesList).toHaveTextContent(i18n.ASSIGNEES_NO_ASSIGNEES); + }); + + it('should call `onAssigneesApply` on apply button click', () => { + const onAssigneesApplyMock = jest.fn(); + const { getByText, getByTestId } = renderAssigneesApplyPanel({ + assignedUserIds: ['user-id-1'], + onAssigneesApply: onAssigneesApplyMock, + }); + + getByText(mockUserProfiles[1].user.full_name).click(); + getByTestId(ASSIGNEES_APPLY_BUTTON_TEST_ID).click(); + + expect(onAssigneesApplyMock).toHaveBeenCalledTimes(1); + expect(onAssigneesApplyMock).toHaveBeenLastCalledWith(['user-id-2', 'user-id-1']); + }); + + it('should call `onSelectionChange` on user selection', () => { + (useBulkGetUserProfiles as jest.Mock).mockReturnValue({ + isLoading: false, + data: [], + }); + + const onSelectionChangeMock = jest.fn(); + const { getByText } = renderAssigneesApplyPanel({ + assignedUserIds: [], + onSelectionChange: onSelectionChangeMock, + }); + + getByText('User 1').click(); + getByText('User 2').click(); + getByText('User 3').click(); + getByText('User 3').click(); + getByText('User 2').click(); + getByText('User 1').click(); + + expect(onSelectionChangeMock).toHaveBeenCalledTimes(6); + expect(onSelectionChangeMock.mock.calls).toEqual([ + [['user-id-1']], + [['user-id-2', 'user-id-1']], + [['user-id-3', 'user-id-2', 'user-id-1']], + [['user-id-2', 'user-id-1']], + [['user-id-1']], + [[]], + ]); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/assignees/assignees_apply_panel.tsx b/x-pack/plugins/security_solution/public/common/components/assignees/assignees_apply_panel.tsx new file mode 100644 index 0000000000000..a263b660b7536 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/assignees/assignees_apply_panel.tsx @@ -0,0 +1,156 @@ +/* + * 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 { isEqual } from 'lodash/fp'; +import type { FC } from 'react'; +import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; + +import { EuiButton } from '@elastic/eui'; +import { UserProfilesSelectable } from '@kbn/user-profile-components'; + +import { isEmpty } from 'lodash'; +import { useGetCurrentUserProfile } from '../user_profiles/use_get_current_user_profile'; +import * as i18n from './translations'; +import type { AssigneesIdsSelection, AssigneesProfilesSelection } from './types'; +import { NO_ASSIGNEES_VALUE } from './constants'; +import { useSuggestUsers } from '../user_profiles/use_suggest_users'; +import { useBulkGetUserProfiles } from '../user_profiles/use_bulk_get_user_profiles'; +import { bringCurrentUserToFrontAndSort, removeNoAssigneesSelection } from './utils'; +import { ASSIGNEES_APPLY_BUTTON_TEST_ID, ASSIGNEES_APPLY_PANEL_TEST_ID } from './test_ids'; + +export interface AssigneesApplyPanelProps { + /** + * Identifier of search field. + */ + searchInputId?: string; + + /** + * Ids of the users assigned to the alert + */ + assignedUserIds: AssigneesIdsSelection[]; + + /** + * Show "Unassigned" option if needed + */ + showUnassignedOption?: boolean; + + /** + * Callback to handle changing of the assignees selection + */ + onSelectionChange?: (users: AssigneesIdsSelection[]) => void; + + /** + * Callback to handle applying assignees. If provided will show "Apply assignees" button + */ + onAssigneesApply?: (selectedAssignees: AssigneesIdsSelection[]) => void; +} + +/** + * The popover to allow selection of users from a list + */ +export const AssigneesApplyPanel: FC = memo( + ({ + searchInputId, + assignedUserIds, + showUnassignedOption, + onSelectionChange, + onAssigneesApply, + }) => { + const { data: currentUserProfile } = useGetCurrentUserProfile(); + const existingIds = useMemo( + () => new Set(removeNoAssigneesSelection(assignedUserIds)), + [assignedUserIds] + ); + const { isLoading: isLoadingAssignedUsers, data: assignedUsers } = useBulkGetUserProfiles({ + uids: existingIds, + }); + + const [searchTerm, setSearchTerm] = useState(''); + const { isLoading: isLoadingSuggestedUsers, data: userProfiles } = useSuggestUsers({ + searchTerm, + }); + + const searchResultProfiles = useMemo(() => { + const sortedUsers = bringCurrentUserToFrontAndSort(currentUserProfile, userProfiles) ?? []; + + if (showUnassignedOption && isEmpty(searchTerm)) { + return [NO_ASSIGNEES_VALUE, ...sortedUsers]; + } + + return sortedUsers; + }, [currentUserProfile, searchTerm, showUnassignedOption, userProfiles]); + + const [selectedAssignees, setSelectedAssignees] = useState([]); + useEffect(() => { + if (isLoadingAssignedUsers || !assignedUsers) { + return; + } + const hasNoAssigneesSelection = assignedUserIds.find((uid) => uid === NO_ASSIGNEES_VALUE); + const newAssignees = + hasNoAssigneesSelection !== undefined + ? [NO_ASSIGNEES_VALUE, ...assignedUsers] + : assignedUsers; + setSelectedAssignees(newAssignees); + }, [assignedUserIds, assignedUsers, isLoadingAssignedUsers]); + + const handleSelectedAssignees = useCallback( + (newAssignees: AssigneesProfilesSelection[]) => { + if (!isEqual(newAssignees, selectedAssignees)) { + setSelectedAssignees(newAssignees); + onSelectionChange?.(newAssignees.map((assignee) => assignee?.uid ?? NO_ASSIGNEES_VALUE)); + } + }, + [onSelectionChange, selectedAssignees] + ); + + const handleApplyButtonClick = useCallback(() => { + const selectedIds = selectedAssignees.map((assignee) => assignee?.uid ?? NO_ASSIGNEES_VALUE); + onAssigneesApply?.(selectedIds); + }, [onAssigneesApply, selectedAssignees]); + + const selectedStatusMessage = useCallback( + (total: number) => i18n.ASSIGNEES_SELECTION_STATUS_MESSAGE(total), + [] + ); + + const isLoading = isLoadingAssignedUsers || isLoadingSuggestedUsers; + + return ( +
+ { + setSearchTerm(term); + }} + onChange={handleSelectedAssignees} + selectedStatusMessage={selectedStatusMessage} + options={searchResultProfiles} + selectedOptions={selectedAssignees} + isLoading={isLoading} + height={'full'} + singleSelection={false} + searchPlaceholder={i18n.ASSIGNEES_SEARCH_USERS} + clearButtonLabel={i18n.ASSIGNEES_CLEAR_FILTERS} + nullOptionLabel={i18n.ASSIGNEES_NO_ASSIGNEES} + /> + {onAssigneesApply && ( + + {i18n.ASSIGNEES_APPLY_BUTTON} + + )} +
+ ); + } +); + +AssigneesApplyPanel.displayName = 'AssigneesPanel'; diff --git a/x-pack/plugins/security_solution/public/common/components/assignees/assignees_popover.test.tsx b/x-pack/plugins/security_solution/public/common/components/assignees/assignees_popover.test.tsx new file mode 100644 index 0000000000000..d26cb35c1fc9e --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/assignees/assignees_popover.test.tsx @@ -0,0 +1,98 @@ +/* + * 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 { render } from '@testing-library/react'; + +import { ASSIGNEES_APPLY_PANEL_TEST_ID } from './test_ids'; +import { AssigneesPopover } from './assignees_popover'; + +import { useGetCurrentUserProfile } from '../user_profiles/use_get_current_user_profile'; +import { useBulkGetUserProfiles } from '../user_profiles/use_bulk_get_user_profiles'; +import { useSuggestUsers } from '../user_profiles/use_suggest_users'; +import { TestProviders } from '../../mock'; +import { mockUserProfiles } from './mocks'; +import { EuiButton } from '@elastic/eui'; + +jest.mock('../user_profiles/use_get_current_user_profile'); +jest.mock('../user_profiles/use_bulk_get_user_profiles'); +jest.mock('../user_profiles/use_suggest_users'); + +const MOCK_BUTTON_TEST_ID = 'mock-assignees-button'; + +const renderAssigneesPopover = ({ + assignedUserIds, + isPopoverOpen, +}: { + assignedUserIds: string[]; + isPopoverOpen: boolean; +}) => { + const assignedProfiles = mockUserProfiles.filter((user) => assignedUserIds.includes(user.uid)); + (useBulkGetUserProfiles as jest.Mock).mockReturnValue({ + isLoading: false, + data: assignedProfiles, + }); + return render( + + } + isPopoverOpen={isPopoverOpen} + closePopover={jest.fn()} + /> + + ); +}; + +describe('', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useGetCurrentUserProfile as jest.Mock).mockReturnValue({ + isLoading: false, + data: mockUserProfiles[0], + }); + (useSuggestUsers as jest.Mock).mockReturnValue({ + isLoading: false, + data: mockUserProfiles, + }); + }); + + it('should render closed popover component', () => { + const { getByTestId, queryByTestId } = renderAssigneesPopover({ + assignedUserIds: [], + isPopoverOpen: false, + }); + + expect(getByTestId(MOCK_BUTTON_TEST_ID)).toBeInTheDocument(); + expect(queryByTestId(ASSIGNEES_APPLY_PANEL_TEST_ID)).not.toBeInTheDocument(); + }); + + it('should render opened popover component', () => { + const { getByTestId } = renderAssigneesPopover({ + assignedUserIds: [], + isPopoverOpen: true, + }); + + expect(getByTestId(MOCK_BUTTON_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(ASSIGNEES_APPLY_PANEL_TEST_ID)).toBeInTheDocument(); + }); + + it('should render assignees', () => { + const { getByTestId } = renderAssigneesPopover({ + assignedUserIds: [], + isPopoverOpen: true, + }); + + const assigneesList = getByTestId('euiSelectableList'); + expect(assigneesList).toHaveTextContent('User 1'); + expect(assigneesList).toHaveTextContent('user1@test.com'); + expect(assigneesList).toHaveTextContent('User 2'); + expect(assigneesList).toHaveTextContent('user2@test.com'); + expect(assigneesList).toHaveTextContent('User 3'); + expect(assigneesList).toHaveTextContent('user3@test.com'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/assignees/assignees_popover.tsx b/x-pack/plugins/security_solution/public/common/components/assignees/assignees_popover.tsx new file mode 100644 index 0000000000000..b392855aaf6f7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/assignees/assignees_popover.tsx @@ -0,0 +1,94 @@ +/* + * 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 { FC, ReactNode } from 'react'; +import React, { memo } from 'react'; + +import { EuiPopover, useGeneratedHtmlId } from '@elastic/eui'; + +import { ASSIGNEES_PANEL_WIDTH } from './constants'; +import { AssigneesApplyPanel } from './assignees_apply_panel'; +import type { AssigneesIdsSelection } from './types'; + +export interface AssigneesPopoverProps { + /** + * Ids of the users assigned to the alert + */ + assignedUserIds: AssigneesIdsSelection[]; + + /** + * Show "Unassigned" option if needed + */ + showUnassignedOption?: boolean; + + /** + * Triggering element for which to align the popover to + */ + button: NonNullable; + + /** + * Boolean to allow popover to be opened or closed + */ + isPopoverOpen: boolean; + + /** + * Callback to handle hiding of the popover + */ + closePopover: () => void; + + /** + * Callback to handle changing of the assignees selection + */ + onSelectionChange?: (users: AssigneesIdsSelection[]) => void; + + /** + * Callback to handle applying assignees + */ + onAssigneesApply?: (selectedAssignees: AssigneesIdsSelection[]) => void; +} + +/** + * The popover to allow selection of users from a list + */ +export const AssigneesPopover: FC = memo( + ({ + assignedUserIds, + showUnassignedOption, + button, + isPopoverOpen, + closePopover, + onSelectionChange, + onAssigneesApply, + }) => { + const searchInputId = useGeneratedHtmlId({ + prefix: 'searchInput', + }); + + return ( + + + + ); + } +); + +AssigneesPopover.displayName = 'AssigneesPopover'; diff --git a/x-pack/plugins/security_solution/public/common/components/assignees/constants.ts b/x-pack/plugins/security_solution/public/common/components/assignees/constants.ts new file mode 100644 index 0000000000000..fe12bff429ea8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/assignees/constants.ts @@ -0,0 +1,10 @@ +/* + * 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 const ASSIGNEES_PANEL_WIDTH = 400; + +export const NO_ASSIGNEES_VALUE = null; diff --git a/x-pack/plugins/security_solution/public/common/components/assignees/mocks.ts b/x-pack/plugins/security_solution/public/common/components/assignees/mocks.ts new file mode 100644 index 0000000000000..a3e578eb4ae30 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/assignees/mocks.ts @@ -0,0 +1,27 @@ +/* + * 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 const mockUserProfiles = [ + { + uid: 'user-id-1', + enabled: true, + user: { username: 'user1', full_name: 'User 1', email: 'user1@test.com' }, + data: {}, + }, + { + uid: 'user-id-2', + enabled: true, + user: { username: 'user2', full_name: 'User 2', email: 'user2@test.com' }, + data: {}, + }, + { + uid: 'user-id-3', + enabled: true, + user: { username: 'user3', full_name: 'User 3', email: 'user3@test.com' }, + data: {}, + }, +]; diff --git a/x-pack/plugins/security_solution/public/common/components/assignees/test_ids.ts b/x-pack/plugins/security_solution/public/common/components/assignees/test_ids.ts new file mode 100644 index 0000000000000..0842e8b5f3e98 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/assignees/test_ids.ts @@ -0,0 +1,12 @@ +/* + * 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. + */ + +const PREFIX = 'securitySolutionAssignees'; + +/* Apply Panel */ +export const ASSIGNEES_APPLY_PANEL_TEST_ID = `${PREFIX}ApplyPanel`; +export const ASSIGNEES_APPLY_BUTTON_TEST_ID = `${PREFIX}ApplyButton`; diff --git a/x-pack/plugins/security_solution/public/common/components/assignees/translations.ts b/x-pack/plugins/security_solution/public/common/components/assignees/translations.ts new file mode 100644 index 0000000000000..fdd22f50aa7cb --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/assignees/translations.ts @@ -0,0 +1,42 @@ +/* + * 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 ASSIGNEES_SELECTION_STATUS_MESSAGE = (total: number) => + i18n.translate('xpack.securitySolution.assignees.totalUsersAssigned', { + defaultMessage: '{total, plural, one {# filter} other {# filters}} selected', + values: { total }, + }); + +export const ASSIGNEES_APPLY_BUTTON = i18n.translate( + 'xpack.securitySolution.assignees.applyButtonTitle', + { + defaultMessage: 'Apply', + } +); + +export const ASSIGNEES_SEARCH_USERS = i18n.translate( + 'xpack.securitySolution.assignees.selectableSearchPlaceholder', + { + defaultMessage: 'Search users', + } +); + +export const ASSIGNEES_CLEAR_FILTERS = i18n.translate( + 'xpack.securitySolution.assignees.clearFilters', + { + defaultMessage: 'Clear filters', + } +); + +export const ASSIGNEES_NO_ASSIGNEES = i18n.translate( + 'xpack.securitySolution.assignees.noAssigneesLabel', + { + defaultMessage: 'No assignees', + } +); diff --git a/x-pack/plugins/security_solution/public/common/components/assignees/types.ts b/x-pack/plugins/security_solution/public/common/components/assignees/types.ts new file mode 100644 index 0000000000000..3ee7b04dc23a2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/assignees/types.ts @@ -0,0 +1,11 @@ +/* + * 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 { UserProfileWithAvatar } from '@kbn/user-profile-components'; + +export type AssigneesIdsSelection = string | null; +export type AssigneesProfilesSelection = UserProfileWithAvatar | null; diff --git a/x-pack/plugins/security_solution/public/common/components/assignees/utils.test.tsx b/x-pack/plugins/security_solution/public/common/components/assignees/utils.test.tsx new file mode 100644 index 0000000000000..0b75e90a91b3f --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/assignees/utils.test.tsx @@ -0,0 +1,44 @@ +/* + * 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 { NO_ASSIGNEES_VALUE } from './constants'; +import { mockUserProfiles } from './mocks'; +import { bringCurrentUserToFrontAndSort, removeNoAssigneesSelection } from './utils'; + +describe('utils', () => { + describe('removeNoAssigneesSelection', () => { + it('should return user ids if `no assignees` has not been passed', () => { + const assignees = ['user1', 'user2', 'user3']; + const ids = removeNoAssigneesSelection(assignees); + expect(ids).toEqual(assignees); + }); + + it('should return user ids and remove `no assignees`', () => { + const assignees = [NO_ASSIGNEES_VALUE, 'user1', 'user2', NO_ASSIGNEES_VALUE, 'user3']; + const ids = removeNoAssigneesSelection(assignees); + expect(ids).toEqual(['user1', 'user2', 'user3']); + }); + }); + + describe('bringCurrentUserToFrontAndSort', () => { + it('should return `undefined` if nothing has been passed', () => { + const sortedProfiles = bringCurrentUserToFrontAndSort(); + expect(sortedProfiles).toBeUndefined(); + }); + + it('should return passed profiles if current user is `undefined`', () => { + const sortedProfiles = bringCurrentUserToFrontAndSort(undefined, mockUserProfiles); + expect(sortedProfiles).toEqual(mockUserProfiles); + }); + + it('should return profiles with the current user on top', () => { + const currentUser = mockUserProfiles[1]; + const sortedProfiles = bringCurrentUserToFrontAndSort(currentUser, mockUserProfiles); + expect(sortedProfiles).toEqual([currentUser, mockUserProfiles[0], mockUserProfiles[2]]); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/assignees/utils.ts b/x-pack/plugins/security_solution/public/common/components/assignees/utils.ts new file mode 100644 index 0000000000000..9eae9503febd0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/assignees/utils.ts @@ -0,0 +1,59 @@ +/* + * 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 { sortBy } from 'lodash'; + +import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; + +import { NO_ASSIGNEES_VALUE } from './constants'; +import type { AssigneesIdsSelection } from './types'; + +const getSortField = (profile: UserProfileWithAvatar) => + profile.user?.full_name?.toLowerCase() ?? + profile.user?.email?.toLowerCase() ?? + profile.user?.username.toLowerCase(); + +const sortProfiles = (profiles?: UserProfileWithAvatar[]) => { + if (!profiles) { + return; + } + + return sortBy(profiles, getSortField); +}; + +const moveCurrentUserToBeginning = ( + currentUserProfile?: T, + profiles?: T[] +) => { + if (!profiles) { + return; + } + + if (!currentUserProfile) { + return profiles; + } + + const currentProfileIndex = profiles.find((profile) => profile.uid === currentUserProfile.uid); + + if (!currentProfileIndex) { + return profiles; + } + + const profilesWithoutCurrentUser = profiles.filter( + (profile) => profile.uid !== currentUserProfile.uid + ); + + return [currentUserProfile, ...profilesWithoutCurrentUser]; +}; + +export const bringCurrentUserToFrontAndSort = ( + currentUserProfile?: UserProfileWithAvatar, + profiles?: UserProfileWithAvatar[] +) => moveCurrentUserToBeginning(currentUserProfile, sortProfiles(profiles)); + +export const removeNoAssigneesSelection = (assignees: AssigneesIdsSelection[]): string[] => + assignees.filter((assignee): assignee is string => assignee !== NO_ASSIGNEES_VALUE); diff --git a/x-pack/plugins/security_solution/public/common/components/filter_group/constants.ts b/x-pack/plugins/security_solution/public/common/components/filter_group/constants.ts index 873355fa60a76..9eef5311b278b 100644 --- a/x-pack/plugins/security_solution/public/common/components/filter_group/constants.ts +++ b/x-pack/plugins/security_solution/public/common/components/filter_group/constants.ts @@ -24,6 +24,7 @@ export const TEST_IDS = { EDIT: 'filter-group__context--edit', DISCARD: `filter-group__context--discard`, }, + FILTER_BY_ASSIGNEES_BUTTON: 'filter-popover-button-assignees', }; export const COMMON_OPTIONS_LIST_CONTROL_INPUTS: Partial = { diff --git a/x-pack/plugins/security_solution/public/common/components/filter_group/filter_by_assignees.test.tsx b/x-pack/plugins/security_solution/public/common/components/filter_group/filter_by_assignees.test.tsx new file mode 100644 index 0000000000000..872d6f8e901a4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/filter_group/filter_by_assignees.test.tsx @@ -0,0 +1,132 @@ +/* + * 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 { render } from '@testing-library/react'; + +import { FilterByAssigneesPopover } from './filter_by_assignees'; +import { TEST_IDS } from './constants'; +import { TestProviders } from '../../mock'; +import type { AssigneesIdsSelection } from '../assignees/types'; + +import { useGetCurrentUserProfile } from '../user_profiles/use_get_current_user_profile'; +import { useBulkGetUserProfiles } from '../user_profiles/use_bulk_get_user_profiles'; +import { useSuggestUsers } from '../user_profiles/use_suggest_users'; +import { useLicense } from '../../hooks/use_license'; +import { useUpsellingMessage } from '../../hooks/use_upselling'; + +jest.mock('../user_profiles/use_get_current_user_profile'); +jest.mock('../user_profiles/use_bulk_get_user_profiles'); +jest.mock('../user_profiles/use_suggest_users'); +jest.mock('../../hooks/use_license'); +jest.mock('../../hooks/use_upselling'); + +const mockUserProfiles = [ + { + uid: 'user-id-1', + enabled: true, + user: { username: 'user1', full_name: 'User 1', email: 'user1@test.com' }, + data: {}, + }, + { + uid: 'user-id-2', + enabled: true, + user: { username: 'user2', full_name: 'User 2', email: 'user2@test.com' }, + data: {}, + }, + { + uid: 'user-id-3', + enabled: true, + user: { username: 'user3', full_name: 'User 3', email: 'user3@test.com' }, + data: {}, + }, +]; + +const renderFilterByAssigneesPopover = ( + alertAssignees: AssigneesIdsSelection[] = [], + onUsersChange = jest.fn() +) => + render( + + + + ); + +describe('', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useGetCurrentUserProfile as jest.Mock).mockReturnValue({ + isLoading: false, + data: mockUserProfiles[0], + }); + (useBulkGetUserProfiles as jest.Mock).mockReturnValue({ + isLoading: false, + data: [], + }); + (useSuggestUsers as jest.Mock).mockReturnValue({ + isLoading: false, + data: mockUserProfiles, + }); + (useLicense as jest.Mock).mockReturnValue({ isPlatinumPlus: () => true }); + (useUpsellingMessage as jest.Mock).mockReturnValue('Go for Platinum!'); + }); + + it('should render closed popover component', () => { + const { getByTestId, queryByTestId } = renderFilterByAssigneesPopover(); + + expect(getByTestId(TEST_IDS.FILTER_BY_ASSIGNEES_BUTTON)).toBeInTheDocument(); + expect(queryByTestId('euiSelectableList')).not.toBeInTheDocument(); + }); + + it('should render opened popover component', () => { + const { getByTestId } = renderFilterByAssigneesPopover(); + + getByTestId(TEST_IDS.FILTER_BY_ASSIGNEES_BUTTON).click(); + expect(getByTestId('euiSelectableList')).toBeInTheDocument(); + }); + + it('should render assignees', () => { + const { getByTestId } = renderFilterByAssigneesPopover(); + + getByTestId(TEST_IDS.FILTER_BY_ASSIGNEES_BUTTON).click(); + + const assigneesList = getByTestId('euiSelectableList'); + expect(assigneesList).toHaveTextContent('User 1'); + expect(assigneesList).toHaveTextContent('user1@test.com'); + expect(assigneesList).toHaveTextContent('User 2'); + expect(assigneesList).toHaveTextContent('user2@test.com'); + expect(assigneesList).toHaveTextContent('User 3'); + expect(assigneesList).toHaveTextContent('user3@test.com'); + }); + + it('should call onUsersChange on closing the popover', () => { + const onUsersChangeMock = jest.fn(); + const { getByTestId, getByText } = renderFilterByAssigneesPopover([], onUsersChangeMock); + + getByTestId(TEST_IDS.FILTER_BY_ASSIGNEES_BUTTON).click(); + + getByText('User 1').click(); + getByText('User 2').click(); + getByText('User 3').click(); + getByText('User 3').click(); + getByText('User 2').click(); + getByText('User 1').click(); + + expect(onUsersChangeMock).toHaveBeenCalledTimes(6); + expect(onUsersChangeMock.mock.calls).toEqual([ + [['user-id-1']], + [['user-id-2', 'user-id-1']], + [['user-id-3', 'user-id-2', 'user-id-1']], + [['user-id-2', 'user-id-1']], + [['user-id-1']], + [[]], + ]); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/filter_group/filter_by_assignees.tsx b/x-pack/plugins/security_solution/public/common/components/filter_group/filter_by_assignees.tsx new file mode 100644 index 0000000000000..fbef830dd1b85 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/filter_group/filter_by_assignees.tsx @@ -0,0 +1,93 @@ +/* + * 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 { FC } from 'react'; +import React, { memo, useCallback, useState } from 'react'; + +import { i18n } from '@kbn/i18n'; +import { EuiFilterButton, EuiFilterGroup, EuiToolTip } from '@elastic/eui'; + +import { TEST_IDS } from './constants'; +import { AssigneesPopover } from '../assignees/assignees_popover'; +import type { AssigneesIdsSelection } from '../assignees/types'; +import { useLicense } from '../../hooks/use_license'; +import { useUpsellingMessage } from '../../hooks/use_upselling'; + +export interface FilterByAssigneesPopoverProps { + /** + * Ids of the users assigned to the alert + */ + assignedUserIds: AssigneesIdsSelection[]; + + /** + * Callback to handle changing of the assignees selection + */ + onSelectionChange?: (users: AssigneesIdsSelection[]) => void; +} + +/** + * The popover to filter alerts by assigned users + */ +export const FilterByAssigneesPopover: FC = memo( + ({ assignedUserIds, onSelectionChange }) => { + const isPlatinumPlus = useLicense().isPlatinumPlus(); + const upsellingMessage = useUpsellingMessage('alert_assignments'); + + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const togglePopover = useCallback(() => setIsPopoverOpen((value) => !value), []); + + const [selectedAssignees, setSelectedAssignees] = + useState(assignedUserIds); + const handleSelectionChange = useCallback( + (users: AssigneesIdsSelection[]) => { + setSelectedAssignees(users); + onSelectionChange?.(users); + }, + [onSelectionChange] + ); + + return ( + + + 0} + numActiveFilters={selectedAssignees.length} + > + {i18n.translate('xpack.securitySolution.filtersGroup.assignees.buttonTitle', { + defaultMessage: 'Assignees', + })} + + + } + isPopoverOpen={isPopoverOpen} + closePopover={togglePopover} + onSelectionChange={handleSelectionChange} + /> + + ); + } +); + +FilterByAssigneesPopover.displayName = 'FilterByAssigneesPopover'; diff --git a/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/alert_bulk_assignees.test.tsx b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/alert_bulk_assignees.test.tsx new file mode 100644 index 0000000000000..abd3db47ea388 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/alert_bulk_assignees.test.tsx @@ -0,0 +1,189 @@ +/* + * 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 { TimelineItem } from '@kbn/timelines-plugin/common'; +import { act, fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import { TestProviders } from '../../../mock'; +import { useGetCurrentUserProfile } from '../../user_profiles/use_get_current_user_profile'; +import { useBulkGetUserProfiles } from '../../user_profiles/use_bulk_get_user_profiles'; +import { useSuggestUsers } from '../../user_profiles/use_suggest_users'; + +import { BulkAlertAssigneesPanel } from './alert_bulk_assignees'; +import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils'; +import { ASSIGNEES_APPLY_BUTTON_TEST_ID } from '../../assignees/test_ids'; + +jest.mock('../../user_profiles/use_get_current_user_profile'); +jest.mock('../../user_profiles/use_bulk_get_user_profiles'); +jest.mock('../../user_profiles/use_suggest_users'); + +const mockUserProfiles = [ + { uid: 'user-id-1', enabled: true, user: { username: 'user1' }, data: {} }, + { uid: 'user-id-2', enabled: true, user: { username: 'user2' }, data: {} }, +]; + +const mockSuggestedUserProfiles = [ + ...mockUserProfiles, + { uid: 'user-id-3', enabled: true, user: { username: 'user3' }, data: {} }, + { uid: 'user-id-4', enabled: true, user: { username: 'user4' }, data: {} }, +]; + +const mockAlertsWithAssignees = [ + { + _id: 'test-id', + data: [ + { + field: ALERT_WORKFLOW_ASSIGNEE_IDS, + value: ['user-id-1', 'user-id-2'], + }, + ], + ecs: { _id: 'test-id' }, + }, + { + _id: 'test-id', + data: [ + { + field: ALERT_WORKFLOW_ASSIGNEE_IDS, + value: ['user-id-1', 'user-id-2'], + }, + ], + ecs: { _id: 'test-id' }, + }, +]; + +(useGetCurrentUserProfile as jest.Mock).mockReturnValue({ + isLoading: false, + data: mockUserProfiles[0], +}); +(useBulkGetUserProfiles as jest.Mock).mockReturnValue({ + isLoading: false, + data: mockUserProfiles, +}); +(useSuggestUsers as jest.Mock).mockReturnValue({ + isLoading: false, + data: mockSuggestedUserProfiles, +}); + +const renderAssigneesMenu = ( + items: TimelineItem[], + closePopover: () => void = jest.fn(), + onSubmit: () => Promise = jest.fn(), + setIsLoading: () => void = jest.fn() +) => { + return render( + + + + ); +}; + +describe('BulkAlertAssigneesPanel', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('it renders', () => { + const wrapper = renderAssigneesMenu(mockAlertsWithAssignees); + + expect(wrapper.getByTestId(ASSIGNEES_APPLY_BUTTON_TEST_ID)).toBeInTheDocument(); + expect(useSuggestUsers).toHaveBeenCalled(); + }); + + test('it calls expected functions on submit when nothing has changed', () => { + const mockedClosePopover = jest.fn(); + const mockedOnSubmit = jest.fn(); + const mockedSetIsLoading = jest.fn(); + + const wrapper = renderAssigneesMenu( + mockAlertsWithAssignees, + mockedClosePopover, + mockedOnSubmit, + mockedSetIsLoading + ); + + act(() => { + fireEvent.click(wrapper.getByTestId(ASSIGNEES_APPLY_BUTTON_TEST_ID)); + }); + expect(mockedClosePopover).toHaveBeenCalled(); + expect(mockedOnSubmit).not.toHaveBeenCalled(); + expect(mockedSetIsLoading).not.toHaveBeenCalled(); + }); + + test('it updates state correctly', () => { + const wrapper = renderAssigneesMenu(mockAlertsWithAssignees); + + const deselectUser = (userName: string, index: number) => { + expect(wrapper.getAllByRole('option')[index]).toHaveAttribute('title', userName); + expect(wrapper.getAllByRole('option')[index]).toBeChecked(); + act(() => { + fireEvent.click(wrapper.getByText(userName)); + }); + expect(wrapper.getAllByRole('option')[index]).toHaveAttribute('title', userName); + expect(wrapper.getAllByRole('option')[index]).not.toBeChecked(); + }; + + const selectUser = (userName: string, index = 0) => { + expect(wrapper.getAllByRole('option')[index]).toHaveAttribute('title', userName); + expect(wrapper.getAllByRole('option')[index]).not.toBeChecked(); + act(() => { + fireEvent.click(wrapper.getByText(userName)); + }); + expect(wrapper.getAllByRole('option')[index]).toHaveAttribute('title', userName); + expect(wrapper.getAllByRole('option')[index]).toBeChecked(); + }; + + deselectUser('user1', 0); + deselectUser('user2', 1); + selectUser('user3', 2); + selectUser('user4', 3); + }); + + test('it calls expected functions on submit when alerts have changed', () => { + const mockedClosePopover = jest.fn(); + const mockedOnSubmit = jest.fn(); + const mockedSetIsLoading = jest.fn(); + + const wrapper = renderAssigneesMenu( + mockAlertsWithAssignees, + mockedClosePopover, + mockedOnSubmit, + mockedSetIsLoading + ); + act(() => { + fireEvent.click(wrapper.getByText('user1')); + }); + act(() => { + fireEvent.click(wrapper.getByText('user2')); + }); + act(() => { + fireEvent.click(wrapper.getByText('user3')); + }); + act(() => { + fireEvent.click(wrapper.getByText('user4')); + }); + + act(() => { + fireEvent.click(wrapper.getByTestId(ASSIGNEES_APPLY_BUTTON_TEST_ID)); + }); + expect(mockedClosePopover).toHaveBeenCalled(); + expect(mockedOnSubmit).toHaveBeenCalled(); + expect(mockedOnSubmit).toHaveBeenCalledWith( + { + add: ['user-id-4', 'user-id-3'], + remove: ['user-id-1', 'user-id-2'], + }, + ['test-id', 'test-id'], + expect.anything(), // An anonymous callback defined in the onSubmit function + mockedSetIsLoading + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/alert_bulk_assignees.tsx b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/alert_bulk_assignees.tsx new file mode 100644 index 0000000000000..9e312e6b366e1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/alert_bulk_assignees.tsx @@ -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 { intersection } from 'lodash'; +import React, { memo, useCallback, useMemo } from 'react'; + +import type { TimelineItem } from '@kbn/timelines-plugin/common'; +import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils'; + +import type { SetAlertAssigneesFunc } from './use_set_alert_assignees'; +import { AssigneesApplyPanel } from '../../assignees/assignees_apply_panel'; +import type { AssigneesIdsSelection } from '../../assignees/types'; +import { removeNoAssigneesSelection } from '../../assignees/utils'; + +interface BulkAlertAssigneesPanelComponentProps { + alertItems: TimelineItem[]; + setIsLoading: (isLoading: boolean) => void; + refresh?: () => void; + clearSelection?: () => void; + closePopoverMenu: () => void; + onSubmit: SetAlertAssigneesFunc; +} +const BulkAlertAssigneesPanelComponent: React.FC = ({ + alertItems, + refresh, + setIsLoading, + clearSelection, + closePopoverMenu, + onSubmit, +}) => { + const assignedUserIds = useMemo( + () => + intersection( + ...alertItems.map( + (item) => + item.data.find((data) => data.field === ALERT_WORKFLOW_ASSIGNEE_IDS)?.value ?? [] + ) + ), + [alertItems] + ); + + const onAssigneesApply = useCallback( + async (assigneesIds: AssigneesIdsSelection[]) => { + const updatedIds = removeNoAssigneesSelection(assigneesIds); + const assigneesToAddArray = updatedIds.filter((uid) => uid && !assignedUserIds.includes(uid)); + const assigneesToRemoveArray = assignedUserIds.filter( + (uid) => uid && !updatedIds.includes(uid) + ); + if (assigneesToAddArray.length === 0 && assigneesToRemoveArray.length === 0) { + closePopoverMenu(); + return; + } + + const ids = alertItems.map((item) => item._id); + const assignees = { + add: assigneesToAddArray, + remove: assigneesToRemoveArray, + }; + const onSuccess = () => { + if (refresh) refresh(); + if (clearSelection) clearSelection(); + }; + if (onSubmit != null) { + closePopoverMenu(); + await onSubmit(assignees, ids, onSuccess, setIsLoading); + } + }, + [alertItems, assignedUserIds, clearSelection, closePopoverMenu, onSubmit, refresh, setIsLoading] + ); + + return ( +
+ +
+ ); +}; + +export const BulkAlertAssigneesPanel = memo(BulkAlertAssigneesPanelComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/translations.ts b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/translations.ts index a99ad3cb76a43..8799911b6e306 100644 --- a/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/translations.ts @@ -211,3 +211,31 @@ export const ALERT_TAGS_CONTEXT_MENU_ITEM_TOOLTIP_INFO = i18n.translate( defaultMessage: 'Change alert tag options in Kibana Advanced Settings.', } ); + +export const UPDATE_ALERT_ASSIGNEES_SUCCESS_TOAST = (totalAlerts: number) => + i18n.translate('xpack.securitySolution.bulkActions.updateAlertAssigneesSuccessToastMessage', { + values: { totalAlerts }, + defaultMessage: + 'Successfully updated assignees for {totalAlerts} {totalAlerts, plural, =1 {alert} other {alerts}}.', + }); + +export const UPDATE_ALERT_ASSIGNEES_FAILURE = i18n.translate( + 'xpack.securitySolution.bulkActions.updateAlertAssigneesFailedToastMessage', + { + defaultMessage: 'Failed to update alert assignees.', + } +); + +export const ALERT_ASSIGNEES_CONTEXT_MENU_ITEM_TITLE = i18n.translate( + 'xpack.securitySolution.bulkActions.alertAssigneesContextMenuItemTitle', + { + defaultMessage: 'Assign alert', + } +); + +export const REMOVE_ALERT_ASSIGNEES_CONTEXT_MENU_TITLE = i18n.translate( + 'xpack.securitySolution.bulkActions.removeAlertAssignessContextMenuTitle', + { + defaultMessage: 'Unassign alert', + } +); diff --git a/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items.test.tsx b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items.test.tsx new file mode 100644 index 0000000000000..7a6b9c87fa27e --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items.test.tsx @@ -0,0 +1,192 @@ +/* + * 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 { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils'; +import { TestProviders } from '@kbn/timelines-plugin/public/mock'; +import type { BulkActionsConfig } from '@kbn/triggers-actions-ui-plugin/public/types'; +import type { TimelineItem } from '@kbn/triggers-actions-ui-plugin/public/application/sections/alerts_table/bulk_actions/components/toolbar'; +import { act, fireEvent, render } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; + +import type { + UseBulkAlertAssigneesItemsProps, + UseBulkAlertAssigneesPanel, +} from './use_bulk_alert_assignees_items'; +import { useBulkAlertAssigneesItems } from './use_bulk_alert_assignees_items'; +import { useSetAlertAssignees } from './use_set_alert_assignees'; +import { useGetCurrentUserProfile } from '../../user_profiles/use_get_current_user_profile'; +import { useBulkGetUserProfiles } from '../../user_profiles/use_bulk_get_user_profiles'; +import { useSuggestUsers } from '../../user_profiles/use_suggest_users'; +import { ASSIGNEES_APPLY_BUTTON_TEST_ID } from '../../assignees/test_ids'; +import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; +import { useLicense } from '../../../hooks/use_license'; + +jest.mock('./use_set_alert_assignees'); +jest.mock('../../user_profiles/use_get_current_user_profile'); +jest.mock('../../user_profiles/use_bulk_get_user_profiles'); +jest.mock('../../user_profiles/use_suggest_users'); +jest.mock('../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'); +jest.mock('../../../hooks/use_license'); + +const mockUserProfiles = [ + { uid: 'user-id-1', enabled: true, user: { username: 'fakeUser1' }, data: {} }, + { uid: 'user-id-2', enabled: true, user: { username: 'fakeUser2' }, data: {} }, +]; + +const defaultProps: UseBulkAlertAssigneesItemsProps = { + onAssigneesUpdate: () => {}, +}; + +const mockAssigneeItems = [ + { + _id: 'test-id', + data: [{ field: ALERT_WORKFLOW_ASSIGNEE_IDS, value: ['user-id-1', 'user-id-2'] }], + ecs: { _id: 'test-id', _index: 'test-index' }, + }, +]; + +const renderPanel = (panel: UseBulkAlertAssigneesPanel) => { + const content = panel.renderContent({ + closePopoverMenu: jest.fn(), + setIsBulkActionsLoading: jest.fn(), + alertItems: mockAssigneeItems, + }); + return render(content); +}; + +describe('useBulkAlertAssigneesItems', () => { + beforeEach(() => { + (useSetAlertAssignees as jest.Mock).mockReturnValue(jest.fn()); + (useGetCurrentUserProfile as jest.Mock).mockReturnValue({ + isLoading: false, + data: mockUserProfiles[0], + }); + (useBulkGetUserProfiles as jest.Mock).mockReturnValue({ + isLoading: false, + data: mockUserProfiles, + }); + (useSuggestUsers as jest.Mock).mockReturnValue({ + isLoading: false, + data: mockUserProfiles, + }); + (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasIndexWrite: true }); + (useLicense as jest.Mock).mockReturnValue({ isPlatinumPlus: () => true }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return two alert assignees action items and one panel', () => { + const { result } = renderHook(() => useBulkAlertAssigneesItems(defaultProps), { + wrapper: TestProviders, + }); + + expect(result.current.alertAssigneesItems.length).toEqual(2); + expect(result.current.alertAssigneesPanels.length).toEqual(1); + + expect(result.current.alertAssigneesItems[0]['data-test-subj']).toEqual( + 'alert-assignees-context-menu-item' + ); + expect(result.current.alertAssigneesItems[1]['data-test-subj']).toEqual( + 'remove-alert-assignees-menu-item' + ); + expect(result.current.alertAssigneesPanels[0]['data-test-subj']).toEqual( + 'alert-assignees-context-menu-panel' + ); + }); + + it('should still render alert assignees panel when useSetAlertAssignees is null', () => { + (useSetAlertAssignees as jest.Mock).mockReturnValue(null); + const { result } = renderHook(() => useBulkAlertAssigneesItems(defaultProps), { + wrapper: TestProviders, + }); + + expect(result.current.alertAssigneesPanels[0]['data-test-subj']).toEqual( + 'alert-assignees-context-menu-panel' + ); + const wrapper = renderPanel(result.current.alertAssigneesPanels[0]); + expect(wrapper.getByTestId('alert-assignees-selectable-menu')).toBeInTheDocument(); + }); + + it('should call setAlertAssignees on submit', () => { + const mockSetAlertAssignees = jest.fn(); + (useSetAlertAssignees as jest.Mock).mockReturnValue(mockSetAlertAssignees); + const { result } = renderHook(() => useBulkAlertAssigneesItems(defaultProps), { + wrapper: TestProviders, + }); + + const wrapper = renderPanel(result.current.alertAssigneesPanels[0]); + expect(wrapper.getByTestId('alert-assignees-selectable-menu')).toBeInTheDocument(); + act(() => { + fireEvent.click(wrapper.getByText('fakeUser2')); // Won't fire unless component assignees selection has been changed + }); + act(() => { + fireEvent.click(wrapper.getByTestId(ASSIGNEES_APPLY_BUTTON_TEST_ID)); + }); + expect(mockSetAlertAssignees).toHaveBeenCalled(); + }); + + it('should call setAlertAssignees with the correct parameters on `Unassign alert` button click', () => { + const mockSetAlertAssignees = jest.fn(); + (useSetAlertAssignees as jest.Mock).mockReturnValue(mockSetAlertAssignees); + const { result } = renderHook(() => useBulkAlertAssigneesItems(defaultProps), { + wrapper: TestProviders, + }); + + const items: TimelineItem[] = [ + { + _id: 'alert1', + data: [{ field: ALERT_WORKFLOW_ASSIGNEE_IDS, value: ['user1', 'user2'] }], + ecs: { _id: 'alert1', _index: 'index1' }, + }, + { + _id: 'alert2', + data: [{ field: ALERT_WORKFLOW_ASSIGNEE_IDS, value: ['user1', 'user3'] }], + ecs: { _id: 'alert2', _index: 'index1' }, + }, + { + _id: 'alert3', + data: [], + ecs: { _id: 'alert3', _index: 'index1' }, + }, + ]; + + const setAlertLoadingMock = jest.fn(); + ( + result.current.alertAssigneesItems[1] as unknown as { onClick: BulkActionsConfig['onClick'] } + ).onClick?.(items, true, setAlertLoadingMock, jest.fn(), jest.fn()); + + expect(mockSetAlertAssignees).toHaveBeenCalled(); + expect(mockSetAlertAssignees).toHaveBeenCalledWith( + { add: [], remove: ['user1', 'user2', 'user3'] }, + ['alert1', 'alert2', 'alert3'], + expect.any(Function), + setAlertLoadingMock + ); + }); + + it('should return 0 items for the VIEWER role', () => { + (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasIndexWrite: false }); + + const { result } = renderHook(() => useBulkAlertAssigneesItems(defaultProps), { + wrapper: TestProviders, + }); + expect(result.current.alertAssigneesItems.length).toEqual(0); + expect(result.current.alertAssigneesPanels.length).toEqual(0); + }); + + it('should return 0 items for the Basic license', () => { + (useLicense as jest.Mock).mockReturnValue({ isPlatinumPlus: () => false }); + + const { result } = renderHook(() => useBulkAlertAssigneesItems(defaultProps), { + wrapper: TestProviders, + }); + expect(result.current.alertAssigneesItems.length).toEqual(0); + expect(result.current.alertAssigneesPanels.length).toEqual(0); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items.tsx b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items.tsx new file mode 100644 index 0000000000000..46fed23c1214b --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items.tsx @@ -0,0 +1,158 @@ +/* + * 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 { union } from 'lodash'; +import React, { useCallback, useMemo } from 'react'; + +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils'; +import type { + BulkActionsConfig, + RenderContentPanelProps, +} from '@kbn/triggers-actions-ui-plugin/public/types'; + +import { useLicense } from '../../../hooks/use_license'; +import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; +import { ASSIGNEES_PANEL_WIDTH } from '../../assignees/constants'; +import { BulkAlertAssigneesPanel } from './alert_bulk_assignees'; +import * as i18n from './translations'; +import { useSetAlertAssignees } from './use_set_alert_assignees'; + +export interface UseBulkAlertAssigneesItemsProps { + onAssigneesUpdate?: () => void; +} + +export interface UseBulkAlertAssigneesPanel { + id: number; + title: JSX.Element; + 'data-test-subj': string; + renderContent: (props: RenderContentPanelProps) => JSX.Element; + width?: number; +} + +export const useBulkAlertAssigneesItems = ({ + onAssigneesUpdate, +}: UseBulkAlertAssigneesItemsProps) => { + const isPlatinumPlus = useLicense().isPlatinumPlus(); + + const { hasIndexWrite } = useAlertsPrivileges(); + const setAlertAssignees = useSetAlertAssignees(); + + const handleOnAlertAssigneesSubmit = useCallback( + async (assignees, ids, onSuccess, setIsLoading) => { + if (setAlertAssignees) { + await setAlertAssignees(assignees, ids, onSuccess, setIsLoading); + } + }, + [setAlertAssignees] + ); + + const onSuccess = useCallback(() => { + onAssigneesUpdate?.(); + }, [onAssigneesUpdate]); + + const onRemoveAllAssignees = useCallback['onClick']>( + async (items, _, setAlertLoading) => { + const ids: string[] = items.map((item) => item._id); + const assignedUserIds = union( + ...items.map( + (item) => + item.data.find((data) => data.field === ALERT_WORKFLOW_ASSIGNEE_IDS)?.value ?? [] + ) + ); + if (!assignedUserIds.length) { + return; + } + const assignees = { + add: [], + remove: assignedUserIds, + }; + if (setAlertAssignees) { + await setAlertAssignees(assignees, ids, onSuccess, setAlertLoading); + } + }, + [onSuccess, setAlertAssignees] + ); + + const alertAssigneesItems = useMemo( + () => + hasIndexWrite && isPlatinumPlus + ? [ + { + key: 'manage-alert-assignees', + 'data-test-subj': 'alert-assignees-context-menu-item', + name: i18n.ALERT_ASSIGNEES_CONTEXT_MENU_ITEM_TITLE, + panel: 2, + label: i18n.ALERT_ASSIGNEES_CONTEXT_MENU_ITEM_TITLE, + disableOnQuery: true, + }, + { + key: 'remove-all-alert-assignees', + 'data-test-subj': 'remove-alert-assignees-menu-item', + name: i18n.REMOVE_ALERT_ASSIGNEES_CONTEXT_MENU_TITLE, + label: i18n.REMOVE_ALERT_ASSIGNEES_CONTEXT_MENU_TITLE, + disableOnQuery: true, + onClick: onRemoveAllAssignees, + }, + ] + : [], + [hasIndexWrite, isPlatinumPlus, onRemoveAllAssignees] + ); + + const TitleContent = useMemo( + () => ( + + {i18n.ALERT_ASSIGNEES_CONTEXT_MENU_ITEM_TITLE} + + ), + [] + ); + + const renderContent = useCallback( + ({ + alertItems, + refresh, + setIsBulkActionsLoading, + clearSelection, + closePopoverMenu, + }: RenderContentPanelProps) => ( + { + onSuccess(); + refresh?.(); + }} + setIsLoading={setIsBulkActionsLoading} + clearSelection={clearSelection} + closePopoverMenu={closePopoverMenu} + onSubmit={handleOnAlertAssigneesSubmit} + /> + ), + [handleOnAlertAssigneesSubmit, onSuccess] + ); + + const alertAssigneesPanels: UseBulkAlertAssigneesPanel[] = useMemo( + () => + hasIndexWrite && isPlatinumPlus + ? [ + { + id: 2, + title: TitleContent, + 'data-test-subj': 'alert-assignees-context-menu-panel', + renderContent, + width: ASSIGNEES_PANEL_WIDTH, + }, + ] + : [], + [TitleContent, hasIndexWrite, isPlatinumPlus, renderContent] + ); + + return { + alertAssigneesItems, + alertAssigneesPanels, + }; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_set_alert_assignees.tsx b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_set_alert_assignees.tsx new file mode 100644 index 0000000000000..43630cda420c7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_set_alert_assignees.tsx @@ -0,0 +1,86 @@ +/* + * 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 { CoreStart } from '@kbn/core/public'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { useCallback, useEffect, useRef } from 'react'; +import type { AlertAssignees } from '../../../../../common/api/detection_engine'; +import { useAppToasts } from '../../../hooks/use_app_toasts'; +import * as i18n from './translations'; +import { setAlertAssignees } from '../../../containers/alert_assignees/api'; + +export type SetAlertAssigneesFunc = ( + assignees: AlertAssignees, + ids: string[], + onSuccess: () => void, + setTableLoading: (param: boolean) => void +) => Promise; +export type ReturnSetAlertAssignees = SetAlertAssigneesFunc | null; + +/** + * Update alert assignees by query + * + * @param assignees to add and/or remove from a batch of alerts + * @param ids alert ids that will be used to create the update query. + * @param onSuccess a callback function that will be called on successful api response + * @param setTableLoading a function that sets the alert table in a loading state for bulk actions + + * + * @throws An error if response is not OK + */ +export const useSetAlertAssignees = (): ReturnSetAlertAssignees => { + const { http } = useKibana().services; + const { addSuccess, addError } = useAppToasts(); + const setAlertAssigneesRef = useRef(null); + + const onUpdateSuccess = useCallback( + (updated: number = 0) => addSuccess(i18n.UPDATE_ALERT_ASSIGNEES_SUCCESS_TOAST(updated)), + [addSuccess] + ); + + const onUpdateFailure = useCallback( + (error: Error) => { + addError(error.message, { title: i18n.UPDATE_ALERT_ASSIGNEES_FAILURE }); + }, + [addError] + ); + + useEffect(() => { + let ignore = false; + const abortCtrl = new AbortController(); + + const onSetAlertAssignees: SetAlertAssigneesFunc = async ( + assignees, + ids, + onSuccess, + setTableLoading + ) => { + try { + setTableLoading(true); + const response = await setAlertAssignees({ assignees, ids, signal: abortCtrl.signal }); + if (!ignore) { + onSuccess(); + setTableLoading(false); + onUpdateSuccess(response.updated); + } + } catch (error) { + if (!ignore) { + setTableLoading(false); + onUpdateFailure(error); + } + } + }; + + setAlertAssigneesRef.current = onSetAlertAssignees; + return (): void => { + ignore = true; + abortCtrl.abort(); + }; + }, [http, onUpdateFailure, onUpdateSuccess]); + + return setAlertAssigneesRef.current; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/user_profiles/__mocks__/api.ts b/x-pack/plugins/security_solution/public/common/components/user_profiles/__mocks__/api.ts new file mode 100644 index 0000000000000..34a24bb4e00f7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/user_profiles/__mocks__/api.ts @@ -0,0 +1,15 @@ +/* + * 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 { UserProfileWithAvatar } from '@kbn/user-profile-components'; +import { mockUserProfiles } from '../mock'; + +export const suggestUsers = async ({ + searchTerm, +}: { + searchTerm: string; +}): Promise => Promise.resolve(mockUserProfiles); diff --git a/x-pack/plugins/security_solution/public/common/components/user_profiles/api.test.ts b/x-pack/plugins/security_solution/public/common/components/user_profiles/api.test.ts new file mode 100644 index 0000000000000..fbd2ee48c9eb6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/user_profiles/api.test.ts @@ -0,0 +1,46 @@ +/* + * 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 { coreMock } from '@kbn/core/public/mocks'; + +import { mockUserProfiles } from './mock'; +import { suggestUsers } from './api'; +import { KibanaServices } from '../../lib/kibana'; +import { DETECTION_ENGINE_ALERT_SUGGEST_USERS_URL } from '../../../../common/constants'; + +const mockKibanaServices = KibanaServices.get as jest.Mock; +jest.mock('../../lib/kibana'); + +const coreStartMock = coreMock.createStart({ basePath: '/mock' }); +mockKibanaServices.mockReturnValue(coreStartMock); +const fetchMock = coreStartMock.http.fetch; + +describe('Detections Alerts API', () => { + describe('suggestUsers', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(mockUserProfiles); + }); + + test('check parameter url', async () => { + await suggestUsers({ searchTerm: 'name1' }); + expect(fetchMock).toHaveBeenCalledWith( + DETECTION_ENGINE_ALERT_SUGGEST_USERS_URL, + expect.objectContaining({ + method: 'GET', + version: '2023-10-31', + query: { searchTerm: 'name1' }, + }) + ); + }); + + test('happy path', async () => { + const alertsResp = await suggestUsers({ searchTerm: '' }); + expect(alertsResp).toEqual(mockUserProfiles); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/user_profiles/api.ts b/x-pack/plugins/security_solution/public/common/components/user_profiles/api.ts new file mode 100644 index 0000000000000..22340d25e0a57 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/user_profiles/api.ts @@ -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 type { UserProfileWithAvatar } from '@kbn/user-profile-components'; + +import type { SuggestUsersProps } from './types'; +import { DETECTION_ENGINE_ALERT_SUGGEST_USERS_URL } from '../../../../common/constants'; +import { KibanaServices } from '../../lib/kibana'; + +/** + * Fetches suggested user profiles + */ +export const suggestUsers = async ({ + searchTerm, +}: SuggestUsersProps): Promise => { + return KibanaServices.get().http.fetch( + DETECTION_ENGINE_ALERT_SUGGEST_USERS_URL, + { + method: 'GET', + version: '2023-10-31', + query: { searchTerm }, + } + ); +}; diff --git a/x-pack/plugins/security_solution/public/common/components/user_profiles/mock.ts b/x-pack/plugins/security_solution/public/common/components/user_profiles/mock.ts new file mode 100644 index 0000000000000..11d1535cc5a50 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/user_profiles/mock.ts @@ -0,0 +1,18 @@ +/* + * 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 const mockCurrentUserProfile = { + uid: 'current-user', + enabled: true, + user: { username: 'current.user' }, + data: {}, +}; + +export const mockUserProfiles = [ + { uid: 'user-id-1', enabled: true, user: { username: 'user1' }, data: {} }, + { uid: 'user-id-2', enabled: true, user: { username: 'user2' }, data: {} }, +]; diff --git a/x-pack/plugins/security_solution/public/common/components/user_profiles/test_ids.ts b/x-pack/plugins/security_solution/public/common/components/user_profiles/test_ids.ts new file mode 100644 index 0000000000000..6a2aa4e259166 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/user_profiles/test_ids.ts @@ -0,0 +1,13 @@ +/* + * 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. + */ + +const PREFIX = 'securitySolutionUsers'; + +/* Avatars */ +export const USER_AVATAR_ITEM_TEST_ID = (userName: string) => `${PREFIX}Avatar-${userName}`; +export const USERS_AVATARS_PANEL_TEST_ID = `${PREFIX}AvatarsPanel`; +export const USERS_AVATARS_COUNT_BADGE_TEST_ID = `${PREFIX}AvatarsCountBadge`; diff --git a/x-pack/plugins/security_solution/public/common/components/user_profiles/translations.ts b/x-pack/plugins/security_solution/public/common/components/user_profiles/translations.ts new file mode 100644 index 0000000000000..6b749e45e3993 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/user_profiles/translations.ts @@ -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 { i18n } from '@kbn/i18n'; + +export const CURRENT_USER_PROFILE_FAILURE = i18n.translate( + 'xpack.securitySolution.userProfiles.fetchCurrentUserProfile.failure', + { defaultMessage: 'Failed to find current user' } +); + +export const USER_PROFILES_FAILURE = i18n.translate( + 'xpack.securitySolution.userProfiles.fetchUserProfiles.failure', + { + defaultMessage: 'Failed to find users', + } +); + +/** + * Used whenever we need to display a user name and for some reason it is not available + */ +export const UNKNOWN_USER_PROFILE_NAME = i18n.translate( + 'xpack.securitySolution.userProfiles.unknownUser.displayName', + { defaultMessage: 'Unknown' } +); diff --git a/x-pack/plugins/security_solution/public/common/components/user_profiles/types.ts b/x-pack/plugins/security_solution/public/common/components/user_profiles/types.ts new file mode 100644 index 0000000000000..2d0586dd571a3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/user_profiles/types.ts @@ -0,0 +1,10 @@ +/* + * 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 interface SuggestUsersProps { + searchTerm: string; +} diff --git a/x-pack/plugins/security_solution/public/common/components/user_profiles/use_bulk_get_user_profiles.test.tsx b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_bulk_get_user_profiles.test.tsx new file mode 100644 index 0000000000000..3861e6a6c8a67 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_bulk_get_user_profiles.test.tsx @@ -0,0 +1,54 @@ +/* + * 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 { renderHook } from '@testing-library/react-hooks'; +import { securityMock } from '@kbn/security-plugin/public/mocks'; + +import { mockUserProfiles } from './mock'; +import { useBulkGetUserProfiles } from './use_bulk_get_user_profiles'; +import { useKibana } from '../../lib/kibana'; +import { useAppToasts } from '../../hooks/use_app_toasts'; +import { useAppToastsMock } from '../../hooks/use_app_toasts.mock'; +import { createStartServicesMock } from '../../lib/kibana/kibana_react.mock'; +import { TestProviders } from '../../mock'; + +jest.mock('../../lib/kibana'); +jest.mock('../../hooks/use_app_toasts'); + +describe('useBulkGetUserProfiles hook', () => { + let appToastsMock: jest.Mocked>; + beforeEach(() => { + jest.clearAllMocks(); + appToastsMock = useAppToastsMock.create(); + (useAppToasts as jest.Mock).mockReturnValue(appToastsMock); + const security = securityMock.createStart(); + security.userProfiles.bulkGet.mockReturnValue(Promise.resolve(mockUserProfiles)); + (useKibana as jest.Mock).mockReturnValue({ + services: { + ...createStartServicesMock(), + security, + }, + }); + }); + + it('returns an array of userProfiles', async () => { + const userProfiles = useKibana().services.security.userProfiles; + const spyOnUserProfiles = jest.spyOn(userProfiles, 'bulkGet'); + const assigneesIds = new Set(['user1']); + const { result, waitForNextUpdate } = renderHook( + () => useBulkGetUserProfiles({ uids: assigneesIds }), + { + wrapper: TestProviders, + } + ); + await waitForNextUpdate(); + + expect(spyOnUserProfiles).toHaveBeenCalledTimes(1); + expect(result.current.isLoading).toEqual(false); + expect(result.current.data).toEqual(mockUserProfiles); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/user_profiles/use_bulk_get_user_profiles.tsx b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_bulk_get_user_profiles.tsx new file mode 100644 index 0000000000000..b74e797162515 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_bulk_get_user_profiles.tsx @@ -0,0 +1,48 @@ +/* + * 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 { SecurityPluginStart } from '@kbn/security-plugin/public'; +import type { UserProfile } from '@kbn/security-plugin/common'; +import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; +import { useQuery } from '@tanstack/react-query'; +import { useKibana } from '../../lib/kibana'; +import { useAppToasts } from '../../hooks/use_app_toasts'; +import { USER_PROFILES_FAILURE } from './translations'; + +export interface BulkGetUserProfilesArgs { + security: SecurityPluginStart; + uids: Set; +} + +export const bulkGetUserProfiles = async ({ + security, + uids, +}: BulkGetUserProfilesArgs): Promise => { + if (uids.size === 0) { + return []; + } + return security.userProfiles.bulkGet({ uids, dataPath: 'avatar' }); +}; + +export const useBulkGetUserProfiles = ({ uids }: { uids: Set }) => { + const { security } = useKibana().services; + const { addError } = useAppToasts(); + + return useQuery( + ['useBulkGetUserProfiles', ...uids], + async () => { + return bulkGetUserProfiles({ security, uids }); + }, + { + retry: false, + staleTime: Infinity, + onError: (e) => { + addError(e, { title: USER_PROFILES_FAILURE }); + }, + } + ); +}; diff --git a/x-pack/plugins/security_solution/public/common/components/user_profiles/use_get_current_user_profile.test.tsx b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_get_current_user_profile.test.tsx new file mode 100644 index 0000000000000..84beb0a8b135b --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_get_current_user_profile.test.tsx @@ -0,0 +1,50 @@ +/* + * 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 { renderHook } from '@testing-library/react-hooks'; +import { securityMock } from '@kbn/security-plugin/public/mocks'; + +import { mockCurrentUserProfile } from './mock'; +import { useGetCurrentUserProfile } from './use_get_current_user_profile'; +import { useKibana } from '../../lib/kibana'; +import { useAppToasts } from '../../hooks/use_app_toasts'; +import { useAppToastsMock } from '../../hooks/use_app_toasts.mock'; +import { createStartServicesMock } from '../../lib/kibana/kibana_react.mock'; +import { TestProviders } from '../../mock'; + +jest.mock('../../lib/kibana'); +jest.mock('../../hooks/use_app_toasts'); + +describe('useGetCurrentUserProfile hook', () => { + let appToastsMock: jest.Mocked>; + beforeEach(() => { + jest.clearAllMocks(); + appToastsMock = useAppToastsMock.create(); + (useAppToasts as jest.Mock).mockReturnValue(appToastsMock); + const security = securityMock.createStart(); + security.userProfiles.getCurrent.mockReturnValue(Promise.resolve(mockCurrentUserProfile)); + (useKibana as jest.Mock).mockReturnValue({ + services: { + ...createStartServicesMock(), + security, + }, + }); + }); + + it('returns current user', async () => { + const userProfiles = useKibana().services.security.userProfiles; + const spyOnUserProfiles = jest.spyOn(userProfiles, 'getCurrent'); + const { result, waitForNextUpdate } = renderHook(() => useGetCurrentUserProfile(), { + wrapper: TestProviders, + }); + await waitForNextUpdate(); + + expect(spyOnUserProfiles).toHaveBeenCalledTimes(1); + expect(result.current.isLoading).toEqual(false); + expect(result.current.data).toEqual(mockCurrentUserProfile); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/user_profiles/use_get_current_user_profile.tsx b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_get_current_user_profile.tsx new file mode 100644 index 0000000000000..fbb4bb0660407 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_get_current_user_profile.tsx @@ -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 { useQuery } from '@tanstack/react-query'; + +import type { SecurityPluginStart } from '@kbn/security-plugin/public'; +import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; + +import { CURRENT_USER_PROFILE_FAILURE } from './translations'; +import { useKibana } from '../../lib/kibana'; +import { useAppToasts } from '../../hooks/use_app_toasts'; + +export const getCurrentUserProfile = async ({ + security, +}: { + security: SecurityPluginStart; +}): Promise => { + return security.userProfiles.getCurrent({ dataPath: 'avatar' }); +}; + +/** + * Fetches current user profile using `userProfiles` service via `security.userProfiles.getCurrent()` + * + * NOTE: There is a similar hook `useCurrentUser` which fetches current authenticated user via `security.authc.getCurrentUser()` + */ +export const useGetCurrentUserProfile = () => { + const { security } = useKibana().services; + const { addError } = useAppToasts(); + + return useQuery( + ['useGetCurrentUserProfile'], + async () => { + return getCurrentUserProfile({ security }); + }, + { + retry: false, + staleTime: Infinity, + onError: (e) => { + addError(e, { title: CURRENT_USER_PROFILE_FAILURE }); + }, + } + ); +}; diff --git a/x-pack/plugins/security_solution/public/common/components/user_profiles/use_suggest_users.test.tsx b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_suggest_users.test.tsx new file mode 100644 index 0000000000000..2cb727942ed57 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_suggest_users.test.tsx @@ -0,0 +1,38 @@ +/* + * 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 { renderHook } from '@testing-library/react-hooks'; +import { useSuggestUsers } from './use_suggest_users'; + +import * as api from './api'; +import { mockUserProfiles } from './mock'; +import { useAppToasts } from '../../hooks/use_app_toasts'; +import { useAppToastsMock } from '../../hooks/use_app_toasts.mock'; +import { TestProviders } from '../../mock'; + +jest.mock('./api'); +jest.mock('../../hooks/use_app_toasts'); + +describe('useSuggestUsers hook', () => { + let appToastsMock: jest.Mocked>; + beforeEach(() => { + jest.clearAllMocks(); + appToastsMock = useAppToastsMock.create(); + (useAppToasts as jest.Mock).mockReturnValue(appToastsMock); + }); + + it('returns an array of userProfiles', async () => { + const spyOnUserProfiles = jest.spyOn(api, 'suggestUsers'); + const { result, waitForNextUpdate } = renderHook(() => useSuggestUsers({ searchTerm: '' }), { + wrapper: TestProviders, + }); + await waitForNextUpdate(); + expect(spyOnUserProfiles).toHaveBeenCalledTimes(1); + expect(result.current.isLoading).toEqual(false); + expect(result.current.data).toEqual(mockUserProfiles); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/user_profiles/use_suggest_users.tsx b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_suggest_users.tsx new file mode 100644 index 0000000000000..a8a2338e51e9d --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_suggest_users.tsx @@ -0,0 +1,44 @@ +/* + * 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 { useQuery } from '@tanstack/react-query'; + +import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; + +import { suggestUsers } from './api'; +import { USER_PROFILES_FAILURE } from './translations'; +import { useAppToasts } from '../../hooks/use_app_toasts'; + +export interface SuggestUserProfilesArgs { + searchTerm: string; +} + +export const bulkGetUserProfiles = async ({ + searchTerm, +}: { + searchTerm: string; +}): Promise => { + return suggestUsers({ searchTerm }); +}; + +export const useSuggestUsers = ({ searchTerm }: { searchTerm: string }) => { + const { addError } = useAppToasts(); + + return useQuery( + ['useSuggestUsers', searchTerm], + async () => { + return bulkGetUserProfiles({ searchTerm }); + }, + { + retry: false, + staleTime: Infinity, + onError: (e) => { + addError(e, { title: USER_PROFILES_FAILURE }); + }, + } + ); +}; diff --git a/x-pack/plugins/security_solution/public/common/components/user_profiles/users_avatars_panel.test.tsx b/x-pack/plugins/security_solution/public/common/components/user_profiles/users_avatars_panel.test.tsx new file mode 100644 index 0000000000000..725cb81aea3e0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/user_profiles/users_avatars_panel.test.tsx @@ -0,0 +1,58 @@ +/* + * 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 { render } from '@testing-library/react'; + +import { UsersAvatarsPanel } from './users_avatars_panel'; + +import { TestProviders } from '../../mock'; +import { mockUserProfiles } from '../assignees/mocks'; +import { + USERS_AVATARS_COUNT_BADGE_TEST_ID, + USERS_AVATARS_PANEL_TEST_ID, + USER_AVATAR_ITEM_TEST_ID, +} from './test_ids'; + +const renderUsersAvatarsPanel = (userProfiles = [mockUserProfiles[0]], maxVisibleAvatars = 1) => + render( + + + + ); + +describe('', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render component', () => { + const { getByTestId } = renderUsersAvatarsPanel(); + + expect(getByTestId(USERS_AVATARS_PANEL_TEST_ID)).toBeInTheDocument(); + }); + + it('should render avatars for all assignees', () => { + const assignees = [mockUserProfiles[0], mockUserProfiles[1]]; + const { getByTestId, queryByTestId } = renderUsersAvatarsPanel(assignees, 2); + + expect(getByTestId(USER_AVATAR_ITEM_TEST_ID('user1'))).toBeInTheDocument(); + expect(getByTestId(USER_AVATAR_ITEM_TEST_ID('user2'))).toBeInTheDocument(); + + expect(queryByTestId(USERS_AVATARS_COUNT_BADGE_TEST_ID)).not.toBeInTheDocument(); + }); + + it('should render badge with number of assignees if exceeds `maxVisibleAvatars`', () => { + const assignees = [mockUserProfiles[0], mockUserProfiles[1]]; + const { getByTestId, queryByTestId } = renderUsersAvatarsPanel(assignees, 1); + + expect(getByTestId(USERS_AVATARS_COUNT_BADGE_TEST_ID)).toBeInTheDocument(); + + expect(queryByTestId(USER_AVATAR_ITEM_TEST_ID('user1'))).not.toBeInTheDocument(); + expect(queryByTestId(USER_AVATAR_ITEM_TEST_ID('user2'))).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/user_profiles/users_avatars_panel.tsx b/x-pack/plugins/security_solution/public/common/components/user_profiles/users_avatars_panel.tsx new file mode 100644 index 0000000000000..777ef04060f2b --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/user_profiles/users_avatars_panel.tsx @@ -0,0 +1,80 @@ +/* + * 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 { FC } from 'react'; +import React, { memo } from 'react'; + +import { EuiFlexGroup, EuiFlexItem, EuiNotificationBadge, EuiToolTip } from '@elastic/eui'; +import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; +import { UserAvatar } from '@kbn/user-profile-components'; + +import { UNKNOWN_USER_PROFILE_NAME } from './translations'; +import { + USERS_AVATARS_COUNT_BADGE_TEST_ID, + USERS_AVATARS_PANEL_TEST_ID, + USER_AVATAR_ITEM_TEST_ID, +} from './test_ids'; + +export type UserProfileOrUknown = UserProfileWithAvatar | undefined; + +export interface UsersAvatarsPanelProps { + /** + * The array of user profiles + */ + userProfiles: UserProfileOrUknown[]; + + /** + * Specifies how many avatars should be visible. + * If more assignees passed, then badge with number of assignees will be shown instead. + */ + maxVisibleAvatars?: number; +} + +/** + * Displays users avatars + */ +export const UsersAvatarsPanel: FC = memo( + ({ userProfiles, maxVisibleAvatars }) => { + if (maxVisibleAvatars && userProfiles.length > maxVisibleAvatars) { + return ( + ( +
{user ? user.user.email ?? user.user.username : UNKNOWN_USER_PROFILE_NAME}
+ ))} + repositionOnScroll={true} + > + + {userProfiles.length} + +
+ ); + } + + return ( + + {userProfiles.map((user, index) => ( + + + + ))} + + ); + } +); + +UsersAvatarsPanel.displayName = 'UsersAvatarsPanel'; diff --git a/x-pack/plugins/security_solution/public/common/containers/alert_assignees/api.ts b/x-pack/plugins/security_solution/public/common/containers/alert_assignees/api.ts new file mode 100644 index 0000000000000..8652a51138d62 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/alert_assignees/api.ts @@ -0,0 +1,31 @@ +/* + * 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 { estypes } from '@elastic/elasticsearch'; +import { DETECTION_ENGINE_ALERT_ASSIGNEES_URL } from '../../../../common/constants'; +import type { AlertAssignees } from '../../../../common/api/detection_engine'; +import { KibanaServices } from '../../lib/kibana'; + +export const setAlertAssignees = async ({ + assignees, + ids, + signal, +}: { + assignees: AlertAssignees; + ids: string[]; + signal: AbortSignal | undefined; +}): Promise => { + return KibanaServices.get().http.fetch( + DETECTION_ENGINE_ALERT_ASSIGNEES_URL, + { + method: 'POST', + version: '2023-10-31', + body: JSON.stringify({ assignees, ids }), + signal, + } + ); +}; diff --git a/x-pack/plugins/security_solution/public/common/lib/triggers_actions_ui/register_alerts_table_configuration.tsx b/x-pack/plugins/security_solution/public/common/lib/triggers_actions_ui/register_alerts_table_configuration.tsx index ff75b25832267..6db7a5c814f6f 100644 --- a/x-pack/plugins/security_solution/public/common/lib/triggers_actions_ui/register_alerts_table_configuration.tsx +++ b/x-pack/plugins/security_solution/public/common/lib/triggers_actions_ui/register_alerts_table_configuration.tsx @@ -23,6 +23,7 @@ import { import { getDataTablesInStorageByIds } from '../../../timelines/containers/local_storage'; import { getColumns } from '../../../detections/configurations/security_solution_detections'; import { getRenderCellValueHook } from '../../../detections/configurations/security_solution_detections/render_cell_value'; +import { useFetchPageContext } from '../../../detections/configurations/security_solution_detections/fetch_page_context'; import { SourcererScopeName } from '../../store/sourcerer/model'; const registerAlertsTableConfiguration = ( @@ -64,6 +65,7 @@ const registerAlertsTableConfiguration = ( sort, useFieldBrowserOptions: getUseTriggersActionsFieldBrowserOptions(SourcererScopeName.detections), showInspectButton: true, + useFetchPageContext, }); // register Alert Table on RuleDetails Page @@ -79,6 +81,7 @@ const registerAlertsTableConfiguration = ( sort, useFieldBrowserOptions: getUseTriggersActionsFieldBrowserOptions(SourcererScopeName.detections), showInspectButton: true, + useFetchPageContext, }); registerIfNotAlready(registry, { @@ -91,6 +94,7 @@ const registerAlertsTableConfiguration = ( useCellActions: getUseCellActionsHook(TableId.alertsOnCasePage), sort, showInspectButton: true, + useFetchPageContext, }); registerIfNotAlready(registry, { @@ -104,6 +108,7 @@ const registerAlertsTableConfiguration = ( usePersistentControls: getPersistentControlsHook(TableId.alertsRiskInputs), sort, showInspectButton: true, + useFetchPageContext, }); }; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.test.tsx index 8807ccf0388d2..b4b0a07fd7ab2 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.test.tsx @@ -7,6 +7,7 @@ import type { ExistsFilter, Filter } from '@kbn/es-query'; import { + buildAlertAssigneesFilter, buildAlertsFilter, buildAlertStatusesFilter, buildAlertStatusFilter, @@ -158,6 +159,47 @@ describe('alerts default_config', () => { }); }); + describe('buildAlertAssigneesFilter', () => { + test('given an empty list of assignees ids will return an empty filter', () => { + const filters: Filter[] = buildAlertAssigneesFilter([]); + expect(filters).toHaveLength(0); + }); + + test('builds filter containing all assignees ids passed into function', () => { + const filters = buildAlertAssigneesFilter(['user-id-1', 'user-id-2', 'user-id-3']); + const expected = { + meta: { + alias: null, + disabled: false, + negate: false, + }, + query: { + bool: { + should: [ + { + term: { + 'kibana.alert.workflow_assignee_ids': 'user-id-1', + }, + }, + { + term: { + 'kibana.alert.workflow_assignee_ids': 'user-id-2', + }, + }, + { + term: { + 'kibana.alert.workflow_assignee_ids': 'user-id-3', + }, + }, + ], + }, + }, + }; + expect(filters).toHaveLength(1); + expect(filters[0]).toEqual(expected); + }); + }); + // TODO: move these tests to ../timelines/components/timeline/body/events/event_column_view.tsx // describe.skip('getAlertActions', () => { // let setEventsLoading: ({ eventIds, isLoading }: SetEventsLoadingProps) => void; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx index 1addd05eb8d96..6065e617c1254 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx @@ -9,11 +9,13 @@ import { ALERT_BUILDING_BLOCK_TYPE, ALERT_WORKFLOW_STATUS, ALERT_RULE_RULE_ID, + ALERT_WORKFLOW_ASSIGNEE_IDS, } from '@kbn/rule-data-utils'; import type { Filter } from '@kbn/es-query'; import { tableDefaults } from '@kbn/securitysolution-data-table'; import type { SubsetDataTableModel } from '@kbn/securitysolution-data-table'; +import type { AssigneesIdsSelection } from '../../../common/components/assignees/types'; import type { Status } from '../../../../common/api/detection_engine'; import { getColumns, @@ -152,6 +154,36 @@ export const buildThreatMatchFilter = (showOnlyThreatIndicatorAlerts: boolean): ] : []; +export const buildAlertAssigneesFilter = (assigneesIds: AssigneesIdsSelection[]): Filter[] => { + if (!assigneesIds.length) { + return []; + } + const combinedQuery = { + bool: { + should: assigneesIds.map((id) => + id + ? { + term: { + [ALERT_WORKFLOW_ASSIGNEE_IDS]: id, + }, + } + : { bool: { must_not: { exists: { field: ALERT_WORKFLOW_ASSIGNEE_IDS } } } } + ), + }, + }; + + return [ + { + meta: { + alias: null, + negate: false, + disabled: false, + }, + query: combinedQuery, + }, + ]; +}; + export const getAlertsDefaultModel = (license?: LicenseService): SubsetDataTableModel => ({ ...tableDefaults, columns: getColumns(license), @@ -177,6 +209,7 @@ export const requiredFieldsForActions = [ '@timestamp', 'kibana.alert.workflow_status', 'kibana.alert.workflow_tags', + 'kibana.alert.workflow_assignee_ids', 'kibana.alert.group.id', 'kibana.alert.original_time', 'kibana.alert.building_block_type', diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx index a8e13bb8c5c27..b91db35cdeaa7 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx @@ -28,6 +28,10 @@ jest.mock('../../../../common/hooks/use_experimental_features', () => ({ useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true), })); +jest.mock('../../../../common/hooks/use_license', () => ({ + useLicense: jest.fn().mockReturnValue({ isPlatinumPlus: () => true }), +})); + const ecsRowData: Ecs = { _id: '1', agent: { type: ['blah'] }, @@ -105,6 +109,7 @@ const markAsAcknowledgedButton = '[data-test-subj="acknowledged-alert-status"]'; const markAsClosedButton = '[data-test-subj="close-alert-status"]'; const addEndpointEventFilterButton = '[data-test-subj="add-event-filter-menu-item"]'; const applyAlertTagsButton = '[data-test-subj="alert-tags-context-menu-item"]'; +const applyAlertAssigneesButton = '[data-test-subj="alert-assignees-context-menu-item"]'; describe('Alert table context menu', () => { describe('Case actions', () => { @@ -304,4 +309,16 @@ describe('Alert table context menu', () => { expect(wrapper.find(applyAlertTagsButton).first().exists()).toEqual(true); }); }); + + describe('Assign alert action', () => { + test('it renders the assign alert action button', () => { + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + + wrapper.find(actionMenuButton).simulate('click'); + + expect(wrapper.find(applyAlertAssigneesButton).first().exists()).toEqual(true); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx index 741039c09e0e2..c5c5b95abbccd 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx @@ -50,6 +50,7 @@ import { isAlertFromEndpointAlert } from '../../../../common/utils/endpoint_aler import type { Rule } from '../../../../detection_engine/rule_management/logic/types'; import type { AlertTableContextMenuItem } from '../types'; import { useAlertTagsActions } from './use_alert_tags_actions'; +import { useAlertAssigneesActions } from './use_alert_assignees_actions'; interface AlertContextMenuProps { ariaLabel?: string; @@ -224,6 +225,12 @@ const AlertContextMenuComponent: React.FC = ({ refetch: refetchAll, }); + const { alertAssigneesItems, alertAssigneesPanels } = useAlertAssigneesActions({ + closePopover, + ecsRowData, + refetch: refetchAll, + }); + const items: AlertTableContextMenuItem[] = useMemo( () => !isEvent && ruleId @@ -231,6 +238,7 @@ const AlertContextMenuComponent: React.FC = ({ ...addToCaseActionItems, ...statusActionItems, ...alertTagsItems, + ...alertAssigneesItems, ...exceptionActionItems, ...(agentId ? osqueryActionItems : []), ] @@ -250,6 +258,7 @@ const AlertContextMenuComponent: React.FC = ({ eventFilterActionItems, canCreateEndpointEventFilters, alertTagsItems, + alertAssigneesItems, ] ); @@ -260,8 +269,9 @@ const AlertContextMenuComponent: React.FC = ({ items, }, ...alertTagsPanels, + ...alertAssigneesPanels, ], - [alertTagsPanels, items] + [alertTagsPanels, alertAssigneesPanels, items] ); const osqueryFlyout = useMemo(() => { diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_assignees_actions.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_assignees_actions.test.tsx new file mode 100644 index 0000000000000..52c196fa729fc --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_assignees_actions.test.tsx @@ -0,0 +1,203 @@ +/* + * 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 { TestProviders } from '@kbn/timelines-plugin/public/mock'; +import { renderHook } from '@testing-library/react-hooks'; +import type { UseAlertAssigneesActionsProps } from './use_alert_assignees_actions'; +import { useAlertAssigneesActions } from './use_alert_assignees_actions'; +import { useAlertsPrivileges } from '../../../containers/detection_engine/alerts/use_alerts_privileges'; +import type { AlertTableContextMenuItem } from '../types'; +import { render } from '@testing-library/react'; +import React from 'react'; +import type { EuiContextMenuPanelDescriptor } from '@elastic/eui'; +import { EuiPopover, EuiContextMenu } from '@elastic/eui'; +import { useSetAlertAssignees } from '../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees'; +import { useGetCurrentUserProfile } from '../../../../common/components/user_profiles/use_get_current_user_profile'; +import { useBulkGetUserProfiles } from '../../../../common/components/user_profiles/use_bulk_get_user_profiles'; +import { useSuggestUsers } from '../../../../common/components/user_profiles/use_suggest_users'; +import { useLicense } from '../../../../common/hooks/use_license'; + +jest.mock('../../../containers/detection_engine/alerts/use_alerts_privileges'); +jest.mock('../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees'); +jest.mock('../../../../common/components/user_profiles/use_get_current_user_profile'); +jest.mock('../../../../common/components/user_profiles/use_bulk_get_user_profiles'); +jest.mock('../../../../common/components/user_profiles/use_suggest_users'); +jest.mock('../../../../common/hooks/use_license'); + +const mockUserProfiles = [ + { uid: 'user-id-1', enabled: true, user: { username: 'fakeUser1' }, data: {} }, + { uid: 'user-id-2', enabled: true, user: { username: 'fakeUser2' }, data: {} }, +]; + +const defaultProps: UseAlertAssigneesActionsProps = { + closePopover: jest.fn(), + ecsRowData: { + _id: '123', + kibana: { + alert: { + workflow_assignee_ids: [], + }, + }, + }, + refetch: jest.fn(), +}; + +const renderContextMenu = ( + items: AlertTableContextMenuItem[], + panels: EuiContextMenuPanelDescriptor[] +) => { + const panelsToRender = [{ id: 0, items }, ...panels]; + return render( + {}} + button={<>} + > + + + ); +}; + +describe('useAlertAssigneesActions', () => { + beforeEach(() => { + (useAlertsPrivileges as jest.Mock).mockReturnValue({ + hasIndexWrite: true, + }); + (useLicense as jest.Mock).mockReturnValue({ isPlatinumPlus: () => true }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render alert assignees actions', () => { + const { result } = renderHook(() => useAlertAssigneesActions(defaultProps), { + wrapper: TestProviders, + }); + + expect(result.current.alertAssigneesItems.length).toEqual(2); + expect(result.current.alertAssigneesPanels.length).toEqual(1); + expect(result.current.alertAssigneesItems[0]['data-test-subj']).toEqual( + 'alert-assignees-context-menu-item' + ); + expect(result.current.alertAssigneesItems[1]['data-test-subj']).toEqual( + 'remove-alert-assignees-menu-item' + ); + + expect(result.current.alertAssigneesPanels[0].content).toMatchInlineSnapshot(` + + `); + }); + + it("should not render alert assignees actions if user doesn't have write permissions", () => { + (useAlertsPrivileges as jest.Mock).mockReturnValue({ + hasIndexWrite: false, + }); + const { result } = renderHook(() => useAlertAssigneesActions(defaultProps), { + wrapper: TestProviders, + }); + expect(result.current.alertAssigneesItems.length).toEqual(0); + }); + + it('should not render alert assignees actions within Basic license', () => { + (useLicense as jest.Mock).mockReturnValue({ isPlatinumPlus: () => false }); + const { result } = renderHook(() => useAlertAssigneesActions(defaultProps), { + wrapper: TestProviders, + }); + expect(result.current.alertAssigneesItems.length).toEqual(0); + }); + + it('should still render if workflow_assignee_ids field does not exist', () => { + const newProps = { + ...defaultProps, + ecsRowData: { + _id: '123', + }, + }; + const { result } = renderHook(() => useAlertAssigneesActions(newProps), { + wrapper: TestProviders, + }); + expect(result.current.alertAssigneesItems.length).toEqual(2); + expect(result.current.alertAssigneesPanels.length).toEqual(1); + expect(result.current.alertAssigneesPanels[0].content).toMatchInlineSnapshot(` + + `); + }); + + it('should render the nested panel', async () => { + (useSetAlertAssignees as jest.Mock).mockReturnValue(jest.fn()); + (useGetCurrentUserProfile as jest.Mock).mockReturnValue({ + isLoading: false, + data: mockUserProfiles[0], + }); + (useBulkGetUserProfiles as jest.Mock).mockReturnValue({ + isLoading: false, + data: mockUserProfiles, + }); + (useSuggestUsers as jest.Mock).mockReturnValue({ + isLoading: false, + data: mockUserProfiles, + }); + + const { result } = renderHook(() => useAlertAssigneesActions(defaultProps), { + wrapper: TestProviders, + }); + const alertAssigneesItems = result.current.alertAssigneesItems; + const alertAssigneesPanels = result.current.alertAssigneesPanels; + const { getByTestId } = renderContextMenu(alertAssigneesItems, alertAssigneesPanels); + + expect(getByTestId('alert-assignees-selectable-menu')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_assignees_actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_assignees_actions.tsx new file mode 100644 index 0000000000000..ff7e64a5e4dc8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_assignees_actions.tsx @@ -0,0 +1,94 @@ +/* + * 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 { noop } from 'lodash'; +import { useCallback, useMemo } from 'react'; + +import type { EuiContextMenuPanelDescriptor } from '@elastic/eui'; +import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; +import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils'; + +import { ASSIGNEES_PANEL_WIDTH } from '../../../../common/components/assignees/constants'; +import { useBulkAlertAssigneesItems } from '../../../../common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items'; +import { useAlertsPrivileges } from '../../../containers/detection_engine/alerts/use_alerts_privileges'; +import type { AlertTableContextMenuItem } from '../types'; + +export interface UseAlertAssigneesActionsProps { + closePopover: () => void; + ecsRowData: Ecs; + refetch?: () => void; +} + +export const useAlertAssigneesActions = ({ + closePopover, + ecsRowData, + refetch, +}: UseAlertAssigneesActionsProps) => { + const { hasIndexWrite } = useAlertsPrivileges(); + + const alertId = ecsRowData._id; + const alertAssigneeData = useMemo(() => { + return [ + { + _id: alertId, + _index: ecsRowData._index ?? '', + data: [ + { + field: ALERT_WORKFLOW_ASSIGNEE_IDS, + value: ecsRowData?.kibana?.alert.workflow_assignee_ids ?? [], + }, + ], + ecs: { + _id: alertId, + _index: ecsRowData._index ?? '', + }, + }, + ]; + }, [alertId, ecsRowData._index, ecsRowData?.kibana?.alert.workflow_assignee_ids]); + + const onAssigneesUpdate = useCallback(() => { + closePopover(); + if (refetch) { + refetch(); + } + }, [closePopover, refetch]); + + const { alertAssigneesItems, alertAssigneesPanels } = useBulkAlertAssigneesItems({ + onAssigneesUpdate, + }); + + const itemsToReturn: AlertTableContextMenuItem[] = useMemo( + () => + alertAssigneesItems.map((item) => ({ + name: item.name, + panel: item.panel, + 'data-test-subj': item['data-test-subj'], + key: item.key, + onClick: () => item.onClick?.(alertAssigneeData, false, noop, noop, noop), + })), + [alertAssigneeData, alertAssigneesItems] + ); + + const panelsToReturn: EuiContextMenuPanelDescriptor[] = useMemo( + () => + alertAssigneesPanels.map((panel) => { + const content = panel.renderContent({ + closePopoverMenu: closePopover, + setIsBulkActionsLoading: () => {}, + alertItems: alertAssigneeData, + refresh: onAssigneesUpdate, + }); + return { title: panel.title, content, id: panel.id, width: ASSIGNEES_PANEL_WIDTH }; + }), + [alertAssigneeData, alertAssigneesPanels, closePopover, onAssigneesUpdate] + ); + + return { + alertAssigneesItems: hasIndexWrite ? itemsToReturn : [], + alertAssigneesPanels: panelsToReturn, + }; +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts index 2023df7462536..ae0c05d2bdb05 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts @@ -95,6 +95,13 @@ export const ALERTS_HEADERS_RISK_SCORE = i18n.translate( } ); +export const ALERTS_HEADERS_ASSIGNEES = i18n.translate( + 'xpack.securitySolution.eventsViewer.alerts.defaultHeaders.assigneesTitle', + { + defaultMessage: 'Assignees', + } +); + export const ALERTS_HEADERS_THRESHOLD_COUNT = i18n.translate( 'xpack.securitySolution.eventsViewer.alerts.defaultHeaders.thresholdCount', { diff --git a/x-pack/plugins/security_solution/public/detections/components/detection_page_filters/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/detection_page_filters/index.test.tsx index 984e19a879637..b099acb254538 100644 --- a/x-pack/plugins/security_solution/public/detections/components/detection_page_filters/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/detection_page_filters/index.test.tsx @@ -23,6 +23,35 @@ jest.mock('../../../common/components/filter_group'); jest.mock('../../../common/lib/kibana'); +const mockUserProfiles = [ + { + uid: 'user-id-1', + enabled: true, + user: { username: 'user1', full_name: 'User 1', email: 'user1@test.com' }, + data: {}, + }, + { + uid: 'user-id-2', + enabled: true, + user: { username: 'user2', full_name: 'User 2', email: 'user2@test.com' }, + data: {}, + }, + { + uid: 'user-id-3', + enabled: true, + user: { username: 'user3', full_name: 'User 3', email: 'user3@test.com' }, + data: {}, + }, +]; +jest.mock('../../../common/components/user_profiles/use_suggest_users', () => { + return { + useSuggestUsers: () => ({ + loading: false, + userProfiles: mockUserProfiles, + }), + }; +}); + const basicKibanaServicesMock = createStartServicesMock(); const getFieldByNameMock = jest.fn(() => true); diff --git a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx index cf67bf45fd360..a3e7b942953fb 100644 --- a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx @@ -37,7 +37,10 @@ import { getUserPrivilegesMockDefaultValue } from '../../../common/components/us import { allCasesPermissions } from '../../../cases_test_utils'; import { HostStatus } from '../../../../common/endpoint/types'; import { ENDPOINT_CAPABILITIES } from '../../../../common/endpoint/service/response_actions/constants'; -import { ALERT_TAGS_CONTEXT_MENU_ITEM_TITLE } from '../../../common/components/toolbar/bulk_actions/translations'; +import { + ALERT_ASSIGNEES_CONTEXT_MENU_ITEM_TITLE, + ALERT_TAGS_CONTEXT_MENU_ITEM_TITLE, +} from '../../../common/components/toolbar/bulk_actions/translations'; jest.mock('../../../common/components/user_privileges'); @@ -58,6 +61,10 @@ jest.mock('../../../common/hooks/use_app_toasts', () => ({ }), })); +jest.mock('../../../common/hooks/use_license', () => ({ + useLicense: jest.fn().mockReturnValue({ isPlatinumPlus: () => true }), +})); + jest.mock('../../../common/hooks/use_experimental_features', () => ({ useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true), })); @@ -254,6 +261,13 @@ describe('take action dropdown', () => { ).toEqual(ALERT_TAGS_CONTEXT_MENU_ITEM_TITLE); }); }); + test('should render "Assign alert"', async () => { + await waitFor(() => { + expect( + wrapper.find('[data-test-subj="alert-assignees-context-menu-item"]').first().text() + ).toEqual(ALERT_ASSIGNEES_CONTEXT_MENU_ITEM_TITLE); + }); + }); }); describe('for Endpoint related actions', () => { diff --git a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx index 67175f05ece2e..f8efc47d3bd1a 100644 --- a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx @@ -35,6 +35,7 @@ import { useKibana } from '../../../common/lib/kibana'; import { getOsqueryActionItem } from '../osquery/osquery_action_item'; import type { AlertTableContextMenuItem } from '../alerts_table/types'; import { useAlertTagsActions } from '../alerts_table/timeline_actions/use_alert_tags_actions'; +import { useAlertAssigneesActions } from '../alerts_table/timeline_actions/use_alert_assignees_actions'; interface ActionsData { alertStatus: Status; @@ -189,6 +190,20 @@ export const TakeActionDropdown = React.memo( refetch, }); + const onAssigneesUpdate = useCallback(() => { + if (refetch) { + refetch(); + } + if (refetchFlyoutData) { + refetchFlyoutData(); + } + }, [refetch, refetchFlyoutData]); + const { alertAssigneesItems, alertAssigneesPanels } = useAlertAssigneesActions({ + closePopover: closePopoverHandler, + ecsRowData: ecsData ?? { _id: actionsData.eventId }, + refetch: onAssigneesUpdate, + }); + const { investigateInTimelineActionItems } = useInvestigateInTimeline({ ecsRowData: ecsData, onInvestigateInTimelineAlertClick: closePopoverHandler, @@ -214,7 +229,12 @@ export const TakeActionDropdown = React.memo( const alertsActionItems = useMemo( () => !isEvent && actionsData.ruleId - ? [...statusActionItems, ...alertTagsItems, ...exceptionActionItems] + ? [ + ...statusActionItems, + ...alertTagsItems, + ...alertAssigneesItems, + ...exceptionActionItems, + ] : isEndpointEvent && canCreateEndpointEventFilters ? eventFilterActionItems : [], @@ -227,6 +247,7 @@ export const TakeActionDropdown = React.memo( isEvent, actionsData.ruleId, alertTagsItems, + alertAssigneesItems, ] ); @@ -271,6 +292,7 @@ export const TakeActionDropdown = React.memo( items, }, ...alertTagsPanels, + ...alertAssigneesPanels, ]; const takeActionButton = useMemo( diff --git a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts index 29c8cb4ec0962..bfce842096448 100644 --- a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts +++ b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts @@ -28,6 +28,12 @@ const getBaseColumns = ( > => { const isPlatinumPlus = license?.isPlatinumPlus?.() ?? false; return [ + { + columnHeaderType: defaultColumnHeaderType, + displayAsText: i18n.ALERTS_HEADERS_ASSIGNEES, + id: 'kibana.alert.workflow_assignee_ids', + initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH, + }, { columnHeaderType: defaultColumnHeaderType, displayAsText: i18n.ALERTS_HEADERS_SEVERITY, diff --git a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/fetch_page_context.tsx b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/fetch_page_context.tsx new file mode 100644 index 0000000000000..ebd8df15d92ea --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/fetch_page_context.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; +import type { PreFetchPageContext } from '@kbn/triggers-actions-ui-plugin/public/types'; +import { useBulkGetUserProfiles } from '../../../common/components/user_profiles/use_bulk_get_user_profiles'; + +export interface RenderCellValueContext { + profiles: UserProfileWithAvatar[] | undefined; + isLoading: boolean; +} + +// Add new columns names to this array to render the user's display name instead of profile_uid +export const profileUidColumns = [ + 'kibana.alert.workflow_assignee_ids', + 'kibana.alert.workflow_user', +]; + +export const useFetchPageContext: PreFetchPageContext = ({ + alerts, + columns, +}) => { + const uids = new Set(); + alerts.forEach((alert) => { + profileUidColumns.forEach((columnId) => { + if (columns.find((column) => column.id === columnId) != null) { + const userUids = alert[columnId]; + userUids?.forEach((uid) => uids.add(uid as string)); + } + }); + }); + const result = useBulkGetUserProfiles({ uids }); + const returnVal = useMemo( + () => ({ profiles: result.data, isLoading: result.isLoading }), + [result.data, result.isLoading] + ); + return returnVal; +}; diff --git a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx index 47be8b0739346..84e14ad725e40 100644 --- a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx +++ b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx @@ -33,6 +33,7 @@ import { SUPPRESSED_ALERT_TOOLTIP } from './translations'; import { VIEW_SELECTION } from '../../../../common/constants'; import { getAllFieldsByName } from '../../../common/containers/source'; import { eventRenderedViewColumns, getColumns } from './columns'; +import type { RenderCellValueContext } from './fetch_page_context'; /** * This implementation of `EuiDataGrid`'s `renderCellValue` @@ -95,7 +96,7 @@ export const getRenderCellValueHook = ({ scopeId: SourcererScopeName; tableId: TableId; }) => { - const useRenderCellValue: GetRenderCellValue = () => { + const useRenderCellValue: GetRenderCellValue = ({ context }) => { const { browserFields } = useSourcererDataView(scopeId); const browserFieldsByName = useMemo(() => getAllFieldsByName(browserFields), [browserFields]); const getTable = useMemo(() => dataTableSelectors.getTableByIdSelector(), []); @@ -173,10 +174,11 @@ export const getRenderCellValueHook = ({ scopeId={tableId} truncate={truncate} asPlainText={false} + context={context as RenderCellValueContext} /> ); }, - [browserFieldsByName, browserFields, columnHeaders] + [browserFieldsByName, columnHeaders, browserFields, context] ); return result; }; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/translations.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/translations.ts index 1f667cc42be1e..3af1ddd6c0fce 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/translations.ts @@ -30,3 +30,8 @@ export const CASES_FROM_ALERTS_FAILURE = i18n.translate( 'xpack.securitySolution.endpoint.hostIsolation.casesFromAlerts.title', { defaultMessage: 'Failed to find associated cases' } ); + +export const USER_PROFILES_FAILURE = i18n.translate( + 'xpack.securitySolution.containers.detectionEngine.users.userProfiles.title', + { defaultMessage: 'Failed to find users' } +); diff --git a/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_bulk_actions.tsx b/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_bulk_actions.tsx index 30e86f6185c33..d468f5b05d869 100644 --- a/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_bulk_actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_bulk_actions.tsx @@ -12,6 +12,7 @@ import { isEqual } from 'lodash'; import type { Filter } from '@kbn/es-query'; import { useCallback } from 'react'; import type { TableId } from '@kbn/securitysolution-data-table'; +import { useBulkAlertAssigneesItems } from '../../../common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items'; import { useBulkAlertTagsItems } from '../../../common/components/toolbar/bulk_actions/use_bulk_alert_tags_items'; import type { inputsModel, State } from '../../../common/store'; import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; @@ -93,7 +94,11 @@ export const getBulkActionHook = refetch: refetchGlobalQuery, }); - const items = [...alertActions, timelineAction, ...alertTagsItems]; + const { alertAssigneesItems, alertAssigneesPanels } = useBulkAlertAssigneesItems({ + onAssigneesUpdate: refetchGlobalQuery, + }); + + const items = [...alertActions, timelineAction, ...alertTagsItems, ...alertAssigneesItems]; - return [{ id: 0, items }, ...alertTagsPanels]; + return [{ id: 0, items }, ...alertTagsPanels, ...alertAssigneesPanels]; }; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx index c340c08ad7268..aa196b174131e 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx @@ -33,6 +33,7 @@ import { FilterGroup } from '../../../common/components/filter_group'; import type { AlertsTableComponentProps } from '../../components/alerts_table/alerts_grouping'; import { getMockedFilterGroupWithCustomFilters } from '../../../common/components/filter_group/mocks'; import { TableId } from '@kbn/securitysolution-data-table'; +import { useUpsellingMessage } from '../../../common/hooks/use_upselling'; // Test will fail because we will to need to mock some core services to make the test work // For now let's forget about SiemSearchBar and QueryBar @@ -219,6 +220,7 @@ jest.mock('../../components/alerts_table/timeline_actions/use_add_bulk_to_timeli jest.mock('../../../common/components/visualization_actions/lens_embeddable'); jest.mock('../../../common/components/page/use_refetch_by_session'); +jest.mock('../../../common/hooks/use_upselling'); describe('DetectionEnginePageComponent', () => { beforeAll(() => { @@ -239,6 +241,7 @@ describe('DetectionEnginePageComponent', () => { (FilterGroup as jest.Mock).mockImplementation(() => { return ; }); + (useUpsellingMessage as jest.Mock).mockReturnValue('Go for Platinum!'); }); beforeEach(() => { jest.clearAllMocks(); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx index 8a8b269abf4ed..2127e0c4f26a7 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx @@ -31,6 +31,9 @@ import { tableDefaults, TableId, } from '@kbn/securitysolution-data-table'; +import { isEqual } from 'lodash'; +import { FilterByAssigneesPopover } from '../../../common/components/filter_group/filter_by_assignees'; +import type { AssigneesIdsSelection } from '../../../common/components/assignees/types'; import { ALERTS_TABLE_REGISTRY_CONFIG_IDS } from '../../../../common/constants'; import { useDataTableFilters } from '../../../common/hooks/use_data_table_filters'; import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; @@ -62,6 +65,7 @@ import { showGlobalFilters, } from '../../../timelines/components/timeline/helpers'; import { + buildAlertAssigneesFilter, buildAlertStatusFilter, buildShowBuildingBlockFilter, buildThreatMatchFilter, @@ -135,6 +139,16 @@ const DetectionEnginePageComponent: React.FC = ({ const { loading: listsConfigLoading, needsConfiguration: needsListsConfiguration } = useListsConfig(); + const [assignees, setAssignees] = useState([]); + const handleSelectedAssignees = useCallback( + (newAssignees: AssigneesIdsSelection[]) => { + if (!isEqual(newAssignees, assignees)) { + setAssignees(newAssignees); + } + }, + [assignees] + ); + const arePageFiltersEnabled = useIsExperimentalFeatureEnabled('alertsPageFiltersEnabled'); // when arePageFiltersEnabled === false @@ -176,8 +190,9 @@ const DetectionEnginePageComponent: React.FC = ({ ...filters, ...buildShowBuildingBlockFilter(showBuildingBlockAlerts), ...buildThreatMatchFilter(showOnlyThreatIndicatorAlerts), + ...buildAlertAssigneesFilter(assignees), ]; - }, [showBuildingBlockAlerts, showOnlyThreatIndicatorAlerts, filters]); + }, [assignees, showBuildingBlockAlerts, showOnlyThreatIndicatorAlerts, filters]); const alertPageFilters = useMemo(() => { if (arePageFiltersEnabled) { @@ -247,8 +262,9 @@ const DetectionEnginePageComponent: React.FC = ({ ...buildShowBuildingBlockFilter(showBuildingBlockAlerts), ...buildThreatMatchFilter(showOnlyThreatIndicatorAlerts), ...(alertPageFilters ?? []), + ...buildAlertAssigneesFilter(assignees), ], - [showBuildingBlockAlerts, showOnlyThreatIndicatorAlerts, alertPageFilters] + [assignees, showBuildingBlockAlerts, showOnlyThreatIndicatorAlerts, alertPageFilters] ); const { signalIndexNeedsInit, pollForSignalIndex } = useSignalHelpers(); @@ -363,16 +379,16 @@ const DetectionEnginePageComponent: React.FC = ({ /> ), [ - topLevelFilters, arePageFiltersEnabled, - statusFilter, + from, onFilterGroupChangedCallback, pageFiltersUpdateHandler, - showUpdating, - from, query, + showUpdating, + statusFilter, timelinesUi, to, + topLevelFilters, updatedAt, ] ); @@ -448,14 +464,24 @@ const DetectionEnginePageComponent: React.FC = ({ > - - {i18n.BUTTON_MANAGE_RULES} - + + + + + + + {i18n.BUTTON_MANAGE_RULES} + + + diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees.test.tsx new file mode 100644 index 0000000000000..0753c6f613cb8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees.test.tsx @@ -0,0 +1,166 @@ +/* + * 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 { render } from '@testing-library/react'; + +import { ASSIGNEES_ADD_BUTTON_TEST_ID, ASSIGNEES_TITLE_TEST_ID } from './test_ids'; +import { Assignees } from './assignees'; + +import { useGetCurrentUserProfile } from '../../../../common/components/user_profiles/use_get_current_user_profile'; +import { useBulkGetUserProfiles } from '../../../../common/components/user_profiles/use_bulk_get_user_profiles'; +import { useSuggestUsers } from '../../../../common/components/user_profiles/use_suggest_users'; +import type { SetAlertAssigneesFunc } from '../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees'; +import { useSetAlertAssignees } from '../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees'; +import { TestProviders } from '../../../../common/mock'; +import { ASSIGNEES_APPLY_BUTTON_TEST_ID } from '../../../../common/components/assignees/test_ids'; +import { useLicense } from '../../../../common/hooks/use_license'; +import { useUpsellingMessage } from '../../../../common/hooks/use_upselling'; +import { + USERS_AVATARS_COUNT_BADGE_TEST_ID, + USERS_AVATARS_PANEL_TEST_ID, + USER_AVATAR_ITEM_TEST_ID, +} from '../../../../common/components/user_profiles/test_ids'; +import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; + +jest.mock('../../../../common/components/user_profiles/use_get_current_user_profile'); +jest.mock('../../../../common/components/user_profiles/use_bulk_get_user_profiles'); +jest.mock('../../../../common/components/user_profiles/use_suggest_users'); +jest.mock('../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees'); +jest.mock('../../../../common/hooks/use_license'); +jest.mock('../../../../common/hooks/use_upselling'); +jest.mock('../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'); + +const mockUserProfiles = [ + { uid: 'user-id-1', enabled: true, user: { username: 'user1', full_name: 'User 1' }, data: {} }, + { uid: 'user-id-2', enabled: true, user: { username: 'user2', full_name: 'User 2' }, data: {} }, + { uid: 'user-id-3', enabled: true, user: { username: 'user3', full_name: 'User 3' }, data: {} }, +]; + +const renderAssignees = ( + eventId = 'event-1', + alertAssignees = ['user-id-1'], + onAssigneesUpdated = jest.fn() +) => { + const assignedProfiles = mockUserProfiles.filter((user) => alertAssignees.includes(user.uid)); + (useBulkGetUserProfiles as jest.Mock).mockReturnValue({ + isLoading: false, + data: assignedProfiles, + }); + return render( + + + + ); +}; + +describe('', () => { + let setAlertAssigneesMock: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + (useGetCurrentUserProfile as jest.Mock).mockReturnValue({ + isLoading: false, + data: mockUserProfiles[0], + }); + (useSuggestUsers as jest.Mock).mockReturnValue({ + isLoading: false, + data: mockUserProfiles, + }); + (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasIndexWrite: true }); + (useLicense as jest.Mock).mockReturnValue({ isPlatinumPlus: () => true }); + (useUpsellingMessage as jest.Mock).mockReturnValue('Go for Platinum!'); + + setAlertAssigneesMock = jest.fn().mockReturnValue(Promise.resolve()); + (useSetAlertAssignees as jest.Mock).mockReturnValue(setAlertAssigneesMock); + }); + + it('should render component', () => { + const { getByTestId } = renderAssignees(); + + expect(getByTestId(ASSIGNEES_TITLE_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(USERS_AVATARS_PANEL_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID)).not.toBeDisabled(); + }); + + it('should render assignees avatars', () => { + const assignees = ['user-id-1', 'user-id-2']; + const { getByTestId, queryByTestId } = renderAssignees('test-event', assignees); + + expect(getByTestId(USER_AVATAR_ITEM_TEST_ID('user1'))).toBeInTheDocument(); + expect(getByTestId(USER_AVATAR_ITEM_TEST_ID('user2'))).toBeInTheDocument(); + + expect(queryByTestId(USERS_AVATARS_COUNT_BADGE_TEST_ID)).not.toBeInTheDocument(); + }); + + it('should render badge with assignees count in case there are more than two users assigned to an alert', () => { + const assignees = ['user-id-1', 'user-id-2', 'user-id-3']; + const { getByTestId, queryByTestId } = renderAssignees('test-event', assignees); + + const assigneesCountBadge = getByTestId(USERS_AVATARS_COUNT_BADGE_TEST_ID); + expect(assigneesCountBadge).toBeInTheDocument(); + expect(assigneesCountBadge).toHaveTextContent(`${assignees.length}`); + + expect(queryByTestId(USER_AVATAR_ITEM_TEST_ID('user1'))).not.toBeInTheDocument(); + expect(queryByTestId(USER_AVATAR_ITEM_TEST_ID('user2'))).not.toBeInTheDocument(); + expect(queryByTestId(USER_AVATAR_ITEM_TEST_ID('user3'))).not.toBeInTheDocument(); + }); + + it('should call assignees update functionality with the right arguments', () => { + const assignedProfiles = [mockUserProfiles[0], mockUserProfiles[1]]; + (useBulkGetUserProfiles as jest.Mock).mockReturnValue({ + isLoading: false, + data: assignedProfiles, + }); + + const assignees = assignedProfiles.map((assignee) => assignee.uid); + const { getByTestId, getByText } = renderAssignees('test-event', assignees); + + // Update assignees + getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID).click(); + getByText('User 1').click(); + getByText('User 3').click(); + + // Apply assignees + getByTestId(ASSIGNEES_APPLY_BUTTON_TEST_ID).click(); + + expect(setAlertAssigneesMock).toHaveBeenCalledWith( + { + add: ['user-id-3'], + remove: ['user-id-1'], + }, + ['test-event'], + expect.anything(), + expect.anything() + ); + }); + + it('should render add assignees button as disabled if user has readonly priviliges', () => { + (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasIndexWrite: false }); + + const assignees = ['user-id-1', 'user-id-2']; + const { getByTestId } = renderAssignees('test-event', assignees); + + expect(getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID)).toBeDisabled(); + }); + + it('should render add assignees button as disabled within Basic license', () => { + (useLicense as jest.Mock).mockReturnValue({ isPlatinumPlus: () => false }); + + const assignees = ['user-id-1', 'user-id-2']; + const { getByTestId } = renderAssignees('test-event', assignees); + + expect(getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID)).toBeDisabled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees.tsx new file mode 100644 index 0000000000000..7544388a62f96 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees.tsx @@ -0,0 +1,159 @@ +/* + * 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 { noop } from 'lodash'; +import type { FC } from 'react'; +import React, { memo, useCallback, useMemo, useState } from 'react'; + +import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiTitle, EuiToolTip } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { useUpsellingMessage } from '../../../../common/hooks/use_upselling'; +import { useLicense } from '../../../../common/hooks/use_license'; +import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; +import { useBulkGetUserProfiles } from '../../../../common/components/user_profiles/use_bulk_get_user_profiles'; +import { removeNoAssigneesSelection } from '../../../../common/components/assignees/utils'; +import type { AssigneesIdsSelection } from '../../../../common/components/assignees/types'; +import { AssigneesPopover } from '../../../../common/components/assignees/assignees_popover'; +import { UsersAvatarsPanel } from '../../../../common/components/user_profiles/users_avatars_panel'; +import { useSetAlertAssignees } from '../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees'; +import { + ASSIGNEES_ADD_BUTTON_TEST_ID, + ASSIGNEES_HEADER_TEST_ID, + ASSIGNEES_TITLE_TEST_ID, +} from './test_ids'; + +const UpdateAssigneesButton: FC<{ + isDisabled: boolean; + toolTipMessage: string; + togglePopover: () => void; +}> = memo(({ togglePopover, isDisabled, toolTipMessage }) => ( + + + +)); +UpdateAssigneesButton.displayName = 'UpdateAssigneesButton'; + +export interface AssigneesProps { + /** + * Id of the document + */ + eventId: string; + + /** + * The array of ids of the users assigned to the alert + */ + assignedUserIds: string[]; + + /** + * Callback to handle the successful assignees update + */ + onAssigneesUpdated?: () => void; +} + +/** + * Document assignees details displayed in flyout right section header + */ +export const Assignees: FC = memo( + ({ eventId, assignedUserIds, onAssigneesUpdated }) => { + const isPlatinumPlus = useLicense().isPlatinumPlus(); + const upsellingMessage = useUpsellingMessage('alert_assignments'); + + const { hasIndexWrite } = useAlertsPrivileges(); + const setAlertAssignees = useSetAlertAssignees(); + + const uids = useMemo(() => new Set(assignedUserIds), [assignedUserIds]); + const { data: assignedUsers } = useBulkGetUserProfiles({ uids }); + + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const onSuccess = useCallback(() => { + if (onAssigneesUpdated) onAssigneesUpdated(); + }, [onAssigneesUpdated]); + + const togglePopover = useCallback(() => { + setIsPopoverOpen((value) => !value); + }, []); + + const onAssigneesApply = useCallback( + async (assigneesIds: AssigneesIdsSelection[]) => { + setIsPopoverOpen(false); + if (setAlertAssignees) { + const updatedIds = removeNoAssigneesSelection(assigneesIds); + const assigneesToAddArray = updatedIds.filter((uid) => !assignedUserIds.includes(uid)); + const assigneesToRemoveArray = assignedUserIds.filter((uid) => !updatedIds.includes(uid)); + + const assigneesToUpdate = { + add: assigneesToAddArray, + remove: assigneesToRemoveArray, + }; + + await setAlertAssignees(assigneesToUpdate, [eventId], onSuccess, noop); + } + }, + [assignedUserIds, eventId, onSuccess, setAlertAssignees] + ); + + return ( + + + +

+ +

+
+
+ {assignedUsers && ( + + + + )} + + + } + isPopoverOpen={isPopoverOpen} + closePopover={togglePopover} + onAssigneesApply={onAssigneesApply} + /> + +
+ ); + } +); + +Assignees.displayName = 'Assignees'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/header_title.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/header_title.tsx index c8dfa6e847412..0d8f581626736 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/header_title.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/header_title.tsx @@ -6,26 +6,36 @@ */ import type { FC } from 'react'; -import React, { memo, useMemo } from 'react'; +import React, { memo, useCallback, useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui'; import { isEmpty } from 'lodash'; import { FormattedMessage } from '@kbn/i18n-react'; +import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils'; import { DocumentStatus } from './status'; import { DocumentSeverity } from './severity'; import { RiskScore } from './risk_score'; +import { useRefetchByScope } from '../../../../timelines/components/side_panel/event_details/flyout/use_refetch_by_scope'; import { useBasicDataFromDetailsData } from '../../../../timelines/components/side_panel/event_details/helpers'; import { useRightPanelContext } from '../context'; import { PreferenceFormattedDate } from '../../../../common/components/formatted_date'; import { RenderRuleName } from '../../../../timelines/components/timeline/body/renderers/formatted_field_helpers'; import { SIGNAL_RULE_NAME_FIELD_NAME } from '../../../../timelines/components/timeline/body/renderers/constants'; import { FLYOUT_HEADER_TITLE_TEST_ID } from './test_ids'; +import { Assignees } from './assignees'; import { FlyoutTitle } from '../../../shared/components/flyout_title'; /** * Document details flyout right section header */ export const HeaderTitle: FC = memo(() => { - const { dataFormattedForFieldBrowser, eventId, scopeId, isPreview } = useRightPanelContext(); + const { + dataFormattedForFieldBrowser, + eventId, + scopeId, + isPreview, + refetchFlyoutData, + getFieldsData, + } = useRightPanelContext(); const { isAlert, ruleName, timestamp, ruleId } = useBasicDataFromDetailsData( dataFormattedForFieldBrowser ); @@ -72,6 +82,16 @@ export const HeaderTitle: FC = memo(() => { ); + const { refetch } = useRefetchByScope({ scopeId }); + const alertAssignees = useMemo( + () => (getFieldsData(ALERT_WORKFLOW_ASSIGNEE_IDS) as string[]) ?? [], + [getFieldsData] + ); + const onAssigneesUpdated = useCallback(() => { + refetch(); + refetchFlyoutData(); + }, [refetch, refetchFlyoutData]); + return ( <> @@ -91,6 +111,15 @@ export const HeaderTitle: FC = memo(() => { + {isAlert && !isPreview && ( + + + + )} ); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/test_ids.ts b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/test_ids.ts index 0cbde8fa94e1a..5b176a34014ab 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/test_ids.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/test_ids.ts @@ -19,6 +19,10 @@ export const RISK_SCORE_VALUE_TEST_ID = `${FLYOUT_HEADER_TEST_ID}RiskScoreValue` export const SHARE_BUTTON_TEST_ID = `${FLYOUT_HEADER_TEST_ID}ShareButton` as const; export const CHAT_BUTTON_TEST_ID = 'newChatById' as const; +export const ASSIGNEES_HEADER_TEST_ID = `${FLYOUT_HEADER_TEST_ID}AssigneesHeader` as const; +export const ASSIGNEES_TITLE_TEST_ID = `${FLYOUT_HEADER_TEST_ID}AssigneesTitle` as const; +export const ASSIGNEES_ADD_BUTTON_TEST_ID = `${FLYOUT_HEADER_TEST_ID}AssigneesAddButton` as const; + /* About section */ export const ABOUT_SECTION_TEST_ID = `${PREFIX}AboutSection` as const; diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap index f0e4e31f1e9be..cfcbd3b1be2b2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap @@ -50,7 +50,7 @@ Array [ class="euiFlexItem emotion-euiFlexItem-growZero" >
+
+
+
+

+ Assignees: +

+
+
+
+
+
+
+
+ + + +
+
+
+
+
, @@ -199,7 +256,7 @@ exports[`Details Panel Component DetailsPanel:EventDetails: rendering it should class="euiFlexItem emotion-euiFlexItem-growZero" >
+
+
+
+

+ Assignees: +

+
+
+
+
+
+
+
+ + + +
+
+
+
+
diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx index 82d41e0eafb33..18cef99f0cb20 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx @@ -19,9 +19,13 @@ import { EuiSpacer, EuiCopy, } from '@elastic/eui'; -import React from 'react'; +import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; +import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils'; +import { TableId } from '@kbn/securitysolution-data-table'; +import type { GetFieldsData } from '../../../../common/hooks/use_get_fields_data'; +import { Assignees } from '../../../../flyout/document_details/right/components/assignees'; import { useAssistantAvailability } from '../../../../assistant/use_assistant_availability'; import type { TimelineTabs } from '../../../../../common/types/timeline'; import type { BrowserFields } from '../../../../common/containers/source'; @@ -34,6 +38,7 @@ import { } from '../../../../common/components/event_details/translations'; import { PreferenceFormattedDate } from '../../../../common/components/formatted_date'; import { useGetAlertDetailsFlyoutLink } from './use_get_alert_details_flyout_link'; +import { useRefetchByScope } from './flyout/use_refetch_by_scope'; export type HandleOnEventClosed = () => void; interface Props { @@ -61,6 +66,9 @@ interface ExpandableEventTitleProps { ruleName?: string; timestamp: string; handleOnEventClosed?: HandleOnEventClosed; + scopeId: string; + refetchFlyoutData: () => Promise; + getFieldsData: GetFieldsData; } const StyledEuiFlexGroup = styled(EuiFlexGroup)` @@ -89,6 +97,9 @@ export const ExpandableEventTitle = React.memo( promptContextId, ruleName, timestamp, + scopeId, + refetchFlyoutData, + getFieldsData, }) => { const { hasAssistantPrivilege } = useAssistantAvailability(); const alertDetailsLink = useGetAlertDetailsFlyoutLink({ @@ -97,6 +108,16 @@ export const ExpandableEventTitle = React.memo( timestamp, }); + const { refetch } = useRefetchByScope({ scopeId }); + const alertAssignees = useMemo( + () => (getFieldsData(ALERT_WORKFLOW_ASSIGNEE_IDS) as string[]) ?? [], + [getFieldsData] + ); + const onAssigneesUpdated = useCallback(() => { + refetch(); + refetchFlyoutData(); + }, [refetch, refetchFlyoutData]); + return ( @@ -115,7 +136,7 @@ export const ExpandableEventTitle = React.memo( )} - + {handleOnEventClosed && ( ( )} + {scopeId !== TableId.rulePreview && ( + + + + )} diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/footer.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/footer.tsx index 15d6b2040234a..97a7e9196ae4d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/footer.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/footer.tsx @@ -8,8 +8,6 @@ import React, { useCallback, useMemo, useState } from 'react'; import { EuiFlyoutFooter, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { find } from 'lodash/fp'; -import type { ConnectedProps } from 'react-redux'; -import { connect } from 'react-redux'; import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; import { isActiveTimeline } from '../../../../../helpers'; import { TakeActionDropdown } from '../../../../../detections/components/take_action_dropdown'; @@ -20,9 +18,9 @@ import { EventFiltersFlyout } from '../../../../../management/pages/event_filter import { useEventFilterModal } from '../../../../../detections/components/alerts_table/timeline_actions/use_event_filter_modal'; import { getFieldValue } from '../../../../../detections/components/host_isolation/helpers'; import type { Status } from '../../../../../../common/api/detection_engine'; -import type { inputsModel, State } from '../../../../../common/store'; -import { inputsSelectors } from '../../../../../common/store'; import { OsqueryFlyout } from '../../../../../detections/components/osquery/osquery_flyout'; +import { useRefetchByScope } from './use_refetch_by_scope'; + interface FlyoutFooterProps { detailsData: TimelineEventsDetailsItem[] | null; detailsEcsData: Ecs | null; @@ -43,176 +41,142 @@ interface AddExceptionModalWrapperData { ruleName: string; } -// eslint-disable-next-line react/display-name -export const FlyoutFooterComponent = React.memo( - ({ - detailsData, - detailsEcsData, - handleOnEventClosed, - isHostIsolationPanelOpen, - isReadOnly, - loadingEventDetails, - onAddIsolationStatusClick, - scopeId, - globalQuery, - timelineQuery, - refetchFlyoutData, - }: FlyoutFooterProps & PropsFromRedux) => { - const alertId = detailsEcsData?.kibana?.alert ? detailsEcsData?._id : null; - const ruleIndexRaw = useMemo( - () => - find({ category: 'signal', field: 'signal.rule.index' }, detailsData)?.values ?? - find({ category: 'kibana', field: 'kibana.alert.rule.parameters.index' }, detailsData) - ?.values, - [detailsData] - ); - const ruleIndex = useMemo( - (): string[] | undefined => (Array.isArray(ruleIndexRaw) ? ruleIndexRaw : undefined), - [ruleIndexRaw] - ); - const ruleDataViewIdRaw = useMemo( - () => - find({ category: 'signal', field: 'signal.rule.data_view_id' }, detailsData)?.values ?? - find( - { category: 'kibana', field: 'kibana.alert.rule.parameters.data_view_id' }, - detailsData - )?.values, - [detailsData] - ); - const ruleDataViewId = useMemo( - (): string | undefined => - Array.isArray(ruleDataViewIdRaw) ? ruleDataViewIdRaw[0] : undefined, - [ruleDataViewIdRaw] - ); - - const addExceptionModalWrapperData = useMemo( - () => - [ - { category: 'signal', field: 'signal.rule.id', name: 'ruleId' }, - { category: 'signal', field: 'signal.rule.rule_id', name: 'ruleRuleId' }, - { category: 'signal', field: 'signal.rule.name', name: 'ruleName' }, - { category: 'signal', field: 'kibana.alert.workflow_status', name: 'alertStatus' }, - { category: '_id', field: '_id', name: 'eventId' }, - ].reduce( - (acc, curr) => ({ - ...acc, - [curr.name]: getFieldValue({ category: curr.category, field: curr.field }, detailsData), - }), - {} as AddExceptionModalWrapperData - ), - [detailsData] - ); +export const FlyoutFooterComponent = ({ + detailsData, + detailsEcsData, + handleOnEventClosed, + isHostIsolationPanelOpen, + isReadOnly, + loadingEventDetails, + onAddIsolationStatusClick, + scopeId, + refetchFlyoutData, +}: FlyoutFooterProps) => { + const alertId = detailsEcsData?.kibana?.alert ? detailsEcsData?._id : null; + const ruleIndexRaw = useMemo( + () => + find({ category: 'signal', field: 'signal.rule.index' }, detailsData)?.values ?? + find({ category: 'kibana', field: 'kibana.alert.rule.parameters.index' }, detailsData) + ?.values, + [detailsData] + ); + const ruleIndex = useMemo( + (): string[] | undefined => (Array.isArray(ruleIndexRaw) ? ruleIndexRaw : undefined), + [ruleIndexRaw] + ); + const ruleDataViewIdRaw = useMemo( + () => + find({ category: 'signal', field: 'signal.rule.data_view_id' }, detailsData)?.values ?? + find({ category: 'kibana', field: 'kibana.alert.rule.parameters.data_view_id' }, detailsData) + ?.values, + [detailsData] + ); + const ruleDataViewId = useMemo( + (): string | undefined => (Array.isArray(ruleDataViewIdRaw) ? ruleDataViewIdRaw[0] : undefined), + [ruleDataViewIdRaw] + ); - const refetchQuery = (newQueries: inputsModel.GlobalQuery[]) => { - newQueries.forEach((q) => q.refetch && (q.refetch as inputsModel.Refetch)()); - }; + const addExceptionModalWrapperData = useMemo( + () => + [ + { category: 'signal', field: 'signal.rule.id', name: 'ruleId' }, + { category: 'signal', field: 'signal.rule.rule_id', name: 'ruleRuleId' }, + { category: 'signal', field: 'signal.rule.name', name: 'ruleName' }, + { category: 'signal', field: 'kibana.alert.workflow_status', name: 'alertStatus' }, + { category: '_id', field: '_id', name: 'eventId' }, + ].reduce( + (acc, curr) => ({ + ...acc, + [curr.name]: getFieldValue({ category: curr.category, field: curr.field }, detailsData), + }), + {} as AddExceptionModalWrapperData + ), + [detailsData] + ); - const refetchAll = useCallback(() => { - if (isActiveTimeline(scopeId)) { - refetchQuery([timelineQuery]); - } else { - refetchQuery(globalQuery); - } - }, [scopeId, timelineQuery, globalQuery]); + const { refetch: refetchAll } = useRefetchByScope({ scopeId }); - const { - exceptionFlyoutType, - openAddExceptionFlyout, - onAddExceptionTypeClick, - onAddExceptionCancel, - onAddExceptionConfirm, - } = useExceptionFlyout({ - refetch: refetchAll, - isActiveTimelines: isActiveTimeline(scopeId), - }); - const { closeAddEventFilterModal, isAddEventFilterModalOpen, onAddEventFilterClick } = - useEventFilterModal(); + const { + exceptionFlyoutType, + openAddExceptionFlyout, + onAddExceptionTypeClick, + onAddExceptionCancel, + onAddExceptionConfirm, + } = useExceptionFlyout({ + refetch: refetchAll, + isActiveTimelines: isActiveTimeline(scopeId), + }); + const { closeAddEventFilterModal, isAddEventFilterModalOpen, onAddEventFilterClick } = + useEventFilterModal(); - const [isOsqueryFlyoutOpenWithAgentId, setOsqueryFlyoutOpenWithAgentId] = useState< - null | string - >(null); + const [isOsqueryFlyoutOpenWithAgentId, setOsqueryFlyoutOpenWithAgentId] = useState( + null + ); - const closeOsqueryFlyout = useCallback(() => { - setOsqueryFlyoutOpenWithAgentId(null); - }, [setOsqueryFlyoutOpenWithAgentId]); + const closeOsqueryFlyout = useCallback(() => { + setOsqueryFlyoutOpenWithAgentId(null); + }, [setOsqueryFlyoutOpenWithAgentId]); - if (isReadOnly) { - return null; - } + if (isReadOnly) { + return null; + } - return ( - <> - - - - {detailsEcsData && ( - - )} - - - - {/* This is still wrong to do render flyout/modal inside of the flyout + return ( + <> + + + + {detailsEcsData && ( + + )} + + + + {/* This is still wrong to do render flyout/modal inside of the flyout We need to completely refactor the EventDetails component to be correct */} - {openAddExceptionFlyout && - addExceptionModalWrapperData.ruleId != null && - addExceptionModalWrapperData.ruleRuleId != null && - addExceptionModalWrapperData.eventId != null && ( - - )} - {isAddEventFilterModalOpen && detailsEcsData != null && ( - - )} - {isOsqueryFlyoutOpenWithAgentId && detailsEcsData != null && ( - )} - - ); - } -); - -const makeMapStateToProps = () => { - const getGlobalQueries = inputsSelectors.globalQuery(); - const getTimelineQuery = inputsSelectors.timelineQueryByIdSelector(); - const mapStateToProps = (state: State, { scopeId }: FlyoutFooterProps) => { - return { - globalQuery: getGlobalQueries(state), - timelineQuery: getTimelineQuery(state, scopeId), - }; - }; - return mapStateToProps; + {isAddEventFilterModalOpen && detailsEcsData != null && ( + + )} + {isOsqueryFlyoutOpenWithAgentId && detailsEcsData != null && ( + + )} + + ); }; -const connector = connect(makeMapStateToProps); - -type PropsFromRedux = ConnectedProps; - -export const FlyoutFooter = connector(React.memo(FlyoutFooterComponent)); +export const FlyoutFooter = React.memo(FlyoutFooterComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/header.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/header.tsx index 8b3d50d849c4b..d5df4304a0894 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/header.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/header.tsx @@ -8,6 +8,7 @@ import { EuiFlyoutHeader } from '@elastic/eui'; import React from 'react'; +import type { GetFieldsData } from '../../../../../common/hooks/use_get_fields_data'; import { ExpandableEventTitle } from '../expandable_event'; import { BackToAlertDetailsLink } from './back_to_alert_details_link'; @@ -22,6 +23,9 @@ interface FlyoutHeaderComponentProps { ruleName: string; showAlertDetails: () => void; timestamp: string; + scopeId: string; + refetchFlyoutData: () => Promise; + getFieldsData: GetFieldsData; } const FlyoutHeaderContentComponent = ({ @@ -35,6 +39,9 @@ const FlyoutHeaderContentComponent = ({ ruleName, showAlertDetails, timestamp, + scopeId, + refetchFlyoutData, + getFieldsData, }: FlyoutHeaderComponentProps) => { return ( <> @@ -49,6 +56,9 @@ const FlyoutHeaderContentComponent = ({ promptContextId={promptContextId} ruleName={ruleName} timestamp={timestamp} + scopeId={scopeId} + refetchFlyoutData={refetchFlyoutData} + getFieldsData={getFieldsData} /> )} @@ -67,6 +77,9 @@ const FlyoutHeaderComponent = ({ ruleName, showAlertDetails, timestamp, + scopeId, + refetchFlyoutData, + getFieldsData, }: FlyoutHeaderComponentProps) => { return ( @@ -81,6 +94,9 @@ const FlyoutHeaderComponent = ({ ruleName={ruleName} showAlertDetails={showAlertDetails} timestamp={timestamp} + scopeId={scopeId} + refetchFlyoutData={refetchFlyoutData} + getFieldsData={getFieldsData} /> ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/use_refetch_by_scope.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/use_refetch_by_scope.tsx new file mode 100644 index 0000000000000..efb7c19ba687f --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/use_refetch_by_scope.tsx @@ -0,0 +1,45 @@ +/* + * 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 { useCallback } from 'react'; +import { useDeepEqualSelector } from '../../../../../common/hooks/use_selector'; +import { isActiveTimeline } from '../../../../../helpers'; +import type { inputsModel } from '../../../../../common/store'; +import { inputsSelectors } from '../../../../../common/store'; + +export interface UseRefetchScopeQueryParams { + /** + * Scope ID + */ + scopeId: string; +} + +/** + * Hook to refetch data within specified scope + */ +export const useRefetchByScope = ({ scopeId }: UseRefetchScopeQueryParams) => { + const getGlobalQueries = inputsSelectors.globalQuery(); + const getTimelineQuery = inputsSelectors.timelineQueryByIdSelector(); + const { globalQuery, timelineQuery } = useDeepEqualSelector((state) => ({ + globalQuery: getGlobalQueries(state), + timelineQuery: getTimelineQuery(state, scopeId), + })); + + const refetchQuery = (newQueries: inputsModel.GlobalQuery[]) => { + newQueries.forEach((q) => q.refetch && (q.refetch as inputsModel.Refetch)()); + }; + + const refetchAll = useCallback(() => { + if (isActiveTimeline(scopeId)) { + refetchQuery([timelineQuery]); + } else { + refetchQuery(globalQuery); + } + }, [scopeId, timelineQuery, globalQuery]); + + return { refetch: refetchAll }; +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.test.tsx index 508a5caa590f4..3f7702d490e9d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.test.tsx @@ -22,6 +22,7 @@ import { DEFAULT_PREVIEW_INDEX, ASSISTANT_FEATURE_ID, } from '../../../../../common/constants'; +import { useUpsellingMessage } from '../../../../common/hooks/use_upselling'; const ecsData: Ecs = { _id: '1', @@ -69,6 +70,18 @@ jest.mock( } ); +jest.mock('../../../../common/components/user_profiles/use_bulk_get_user_profiles', () => { + return { + useBulkGetUserProfiles: jest.fn().mockReturnValue({ isLoading: false, data: [] }), + }; +}); + +jest.mock('../../../../common/components/user_profiles/use_suggest_users', () => { + return { + useSuggestUsers: jest.fn().mockReturnValue({ isLoading: false, data: [] }), + }; +}); + jest.mock('../../../../common/hooks/use_experimental_features', () => ({ useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true), })); @@ -112,6 +125,7 @@ jest.mock('../../../../explore/containers/risk_score', () => { }), }; }); +jest.mock('../../../../common/hooks/use_upselling'); const defaultProps = { scopeId: TimelineId.test, @@ -167,6 +181,7 @@ describe('event details panel component', () => { }, }, }); + (useUpsellingMessage as jest.Mock).mockReturnValue('Go for Platinum!'); }); afterEach(() => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx index a2a45e46a07b2..20b6341db6d91 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx @@ -13,6 +13,7 @@ import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; import type { EntityType } from '@kbn/timelines-plugin/common'; +import { useGetFieldsData } from '../../../../common/hooks/use_get_fields_data'; import { useAssistantAvailability } from '../../../../assistant/use_assistant_availability'; import { getRawData } from '../../../../assistant/helpers'; import type { BrowserFields } from '../../../../common/containers/source'; @@ -94,6 +95,7 @@ const EventDetailsPanelComponent: React.FC = ({ skip: !expandedEvent.eventId, } ); + const getFieldsData = useGetFieldsData(rawEventData?.fields); const { isolateAction, @@ -137,6 +139,9 @@ const EventDetailsPanelComponent: React.FC = ({ showAlertDetails={showAlertDetails} timestamp={timestamp} promptContextId={promptContextId} + scopeId={scopeId} + refetchFlyoutData={refetchFlyoutData} + getFieldsData={getFieldsData} /> ) : ( = ({ timestamp={timestamp} handleOnEventClosed={handleOnEventClosed} promptContextId={promptContextId} + scopeId={scopeId} + refetchFlyoutData={refetchFlyoutData} + getFieldsData={getFieldsData} /> ), [ @@ -161,8 +169,11 @@ const EventDetailsPanelComponent: React.FC = ({ ruleName, showAlertDetails, timestamp, - handleOnEventClosed, promptContextId, + handleOnEventClosed, + scopeId, + refetchFlyoutData, + getFieldsData, ] ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.test.tsx index 8895d1307c89b..5e62b7e03bac3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.test.tsx @@ -30,6 +30,18 @@ jest.mock('../../../common/containers/use_search_strategy', () => ({ useSearchStrategy: jest.fn(), })); +jest.mock('../../../common/components/user_profiles/use_bulk_get_user_profiles', () => { + return { + useBulkGetUserProfiles: jest.fn().mockReturnValue({ isLoading: false, data: [] }), + }; +}); + +jest.mock('../../../common/components/user_profiles/use_suggest_users', () => { + return { + useSuggestUsers: jest.fn().mockReturnValue({ isLoading: false, data: [] }), + }; +}); + jest.mock('../../../assistant/use_assistant_availability'); const mockUseLocation = jest.fn().mockReturnValue({ pathname: '/test', search: '?' }); jest.mock('react-router-dom', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/column_renderer.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/column_renderer.ts index a90aef7224a98..9770f2e6f5b67 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/column_renderer.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/column_renderer.ts @@ -11,9 +11,14 @@ import type { Filter } from '@kbn/es-query'; import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; import type { ColumnHeaderOptions, RowRenderer } from '../../../../../../common/types'; import type { TimelineNonEcsData } from '../../../../../../common/search_strategy/timeline'; +import type { RenderCellValueContext } from '../../../../../detections/configurations/security_solution_detections/fetch_page_context'; export interface ColumnRenderer { - isInstance: (columnName: string, data: TimelineNonEcsData[]) => boolean; + isInstance: ( + columnName: string, + data: TimelineNonEcsData[], + context?: RenderCellValueContext + ) => boolean; renderColumn: ({ className, columnName, @@ -28,6 +33,7 @@ export interface ColumnRenderer { truncate, values, key, + context, }: { asPlainText?: boolean; className?: string; @@ -44,5 +50,6 @@ export interface ColumnRenderer { truncate?: boolean; values: string[] | null | undefined; key?: string; + context?: RenderCellValueContext; }) => React.ReactNode; } diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/constants.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/constants.tsx index 9308204e69318..4c3c62b5a61f8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/constants.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/constants.tsx @@ -17,6 +17,7 @@ export const REFERENCE_URL_FIELD_NAME = 'reference.url'; export const EVENT_URL_FIELD_NAME = 'event.url'; export const SIGNAL_RULE_NAME_FIELD_NAME = 'kibana.alert.rule.name'; export const SIGNAL_STATUS_FIELD_NAME = 'kibana.alert.workflow_status'; +export const SIGNAL_ASSIGNEE_IDS_FIELD_NAME = 'kibana.alert.workflow_assignee_ids'; export const AGENT_STATUS_FIELD_NAME = 'agent.status'; export const QUARANTINED_PATH_FIELD_NAME = 'quarantined.path'; export const REASON_FIELD_NAME = 'kibana.alert.reason'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/get_column_renderer.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/get_column_renderer.ts index f94c1c6105533..c6cf20dfcff84 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/get_column_renderer.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/get_column_renderer.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { RenderCellValueContext } from '../../../../../detections/configurations/security_solution_detections/fetch_page_context'; import type { TimelineNonEcsData } from '../../../../../../common/search_strategy/timeline'; import type { ColumnRenderer } from './column_renderer'; @@ -15,10 +16,11 @@ const unhandledColumnRenderer = (): never => { export const getColumnRenderer = ( columnName: string, columnRenderers: ColumnRenderer[], - data: TimelineNonEcsData[] + data: TimelineNonEcsData[], + context?: RenderCellValueContext ): ColumnRenderer => { const renderer = columnRenderers.find((columnRenderer) => - columnRenderer.isInstance(columnName, data) + columnRenderer.isInstance(columnName, data, context) ); return renderer != null ? renderer : unhandledColumnRenderer(); }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/index.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/index.ts index 21493967010fe..a8d8ee67a415b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/index.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/index.ts @@ -18,6 +18,7 @@ import { systemRowRenderers } from './system/generic_row_renderer'; import { threatMatchRowRenderer } from './cti/threat_match_row_renderer'; import { reasonColumnRenderer } from './reason_column_renderer'; import { eventSummaryColumnRenderer } from './event_summary_column_renderer'; +import { userProfileColumnRenderer } from './user_profile_renderer'; // The row renderers are order dependent and will return the first renderer // which returns true from its isInstance call. The bottom renderers which @@ -38,6 +39,7 @@ export const defaultRowRenderers: RowRenderer[] = [ export const columnRenderers: ColumnRenderer[] = [ reasonColumnRenderer, eventSummaryColumnRenderer, + userProfileColumnRenderer, plainColumnRenderer, emptyColumnRenderer, unknownColumnRenderer, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_profile_renderer.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_profile_renderer.tsx new file mode 100644 index 0000000000000..93be8036f0cea --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_profile_renderer.tsx @@ -0,0 +1,58 @@ +/* + * 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 { EuiLoadingSpinner } from '@elastic/eui'; +import React from 'react'; + +import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; +import { UsersAvatarsPanel } from '../../../../../common/components/user_profiles/users_avatars_panel'; +import type { ColumnHeaderOptions, RowRenderer } from '../../../../../../common/types'; +import type { ColumnRenderer } from './column_renderer'; +import { profileUidColumns } from '../../../../../detections/configurations/security_solution_detections/fetch_page_context'; +import type { RenderCellValueContext } from '../../../../../detections/configurations/security_solution_detections/fetch_page_context'; + +export const userProfileColumnRenderer: ColumnRenderer = { + isInstance: (columnName, _, context) => profileUidColumns.includes(columnName) && !!context, + renderColumn: ({ + columnName, + ecsData, + eventId, + field, + isDetails, + isDraggable = true, + linkValues, + rowRenderers = [], + scopeId, + truncate, + values, + context, + }: { + columnName: string; + ecsData?: Ecs; + eventId: string; + field: ColumnHeaderOptions; + isDetails?: boolean; + isDraggable?: boolean; + linkValues?: string[] | null | undefined; + rowRenderers?: RowRenderer[]; + scopeId: string; + truncate?: boolean; + values: string[] | undefined | null; + context?: RenderCellValueContext; + }) => { + // Show spinner if loading profiles or if there are no fetched profiles yet + // Do not show the loading spinner if context is not provided at all + if (context?.isLoading) { + return ; + } + + const userProfiles = + values?.map((uid) => context?.profiles?.find((user) => uid === user.uid)) ?? []; + + return ; + }, +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.test.tsx index 3487e2770ff45..33a47bc8c0698 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.test.tsx @@ -76,7 +76,7 @@ describe('DefaultCellRenderer', () => { ); - expect(getColumnRenderer).toBeCalledWith(header.id, columnRenderers, data); + expect(getColumnRenderer).toBeCalledWith(header.id, columnRenderers, data, undefined); }); test('if in tgrid expanded value, it invokes `renderColumn` with the expected arguments', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.tsx index 551003923a151..9e5006267d32b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.tsx @@ -33,6 +33,7 @@ export const DefaultCellRenderer: React.FC = ({ scopeId, truncate, asPlainText, + context, }) => { const asPlainTextDefault = useMemo(() => { return ( @@ -49,7 +50,7 @@ export const DefaultCellRenderer: React.FC = ({ : 'eui-displayInlineBlock eui-textTruncate'; return ( - {getColumnRenderer(header.id, columnRenderers, data).renderColumn({ + {getColumnRenderer(header.id, columnRenderers, data, context).renderColumn({ asPlainText: asPlainText ?? asPlainTextDefault, // we want to render value with links as plain text but keep other formatters like badge. Except rule name for non preview tables columnName: header.id, ecsData, @@ -62,6 +63,7 @@ export const DefaultCellRenderer: React.FC = ({ scopeId, truncate, values, + context, })} ); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts index 78d8eecc109f3..2f6ea38b40b81 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -506,6 +506,11 @@ export const getSignalsMigrationStatusRequest = () => query: getSignalsMigrationStatusSchemaMock(), }); +export const getMockUserProfiles = () => [ + { uid: 'default-test-assignee-id-1', enabled: true, user: { username: 'user1' }, data: {} }, + { uid: 'default-test-assignee-id-2', enabled: true, user: { username: 'user2' }, data: {} }, +]; + /** * @deprecated Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function */ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/helpers.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/helpers.ts index a557586a008fd..41a5ddfae2be9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/helpers.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/helpers.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { AlertTags } from '../../../../../common/api/detection_engine'; +import type { AlertTags, AlertAssignees } from '../../../../../common/api/detection_engine'; import * as i18n from './translations'; export const validateAlertTagsArrays = (tags: AlertTags, ids: string[]) => { @@ -20,3 +20,13 @@ export const validateAlertTagsArrays = (tags: AlertTags, ids: string[]) => { } return validationErrors; }; + +export const validateAlertAssigneesArrays = (assignees: AlertAssignees) => { + const validationErrors = []; + const { add: assigneesToAdd, remove: assigneesToRemove } = assignees; + const duplicates = assigneesToAdd.filter((assignee) => assigneesToRemove.includes(assignee)); + if (duplicates.length) { + validationErrors.push(i18n.ALERT_ASSIGNEES_VALIDATION_ERROR(JSON.stringify(duplicates))); + } + return validationErrors; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_assignees_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_assignees_route.test.ts new file mode 100644 index 0000000000000..dfc0603598a00 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_assignees_route.test.ts @@ -0,0 +1,120 @@ +/* + * 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 { getSetAlertAssigneesRequestMock } from '../../../../../common/api/detection_engine/alert_assignees/mocks'; +import { DETECTION_ENGINE_ALERT_ASSIGNEES_URL } from '../../../../../common/constants'; +import { requestContextMock, serverMock, requestMock } from '../__mocks__'; +import { getSuccessfulSignalUpdateResponse } from '../__mocks__/request_responses'; +import { setAlertAssigneesRoute } from './set_alert_assignees_route'; + +describe('setAlertAssigneesRoute', () => { + let server: ReturnType; + let request: ReturnType; + let { context } = requestContextMock.createTools(); + + beforeEach(() => { + server = serverMock.create(); + ({ context } = requestContextMock.createTools()); + setAlertAssigneesRoute(server.router); + }); + + describe('happy path', () => { + test('returns 200 when adding/removing empty arrays of assignees', async () => { + request = requestMock.create({ + method: 'post', + path: DETECTION_ENGINE_ALERT_ASSIGNEES_URL, + body: getSetAlertAssigneesRequestMock(['assignee-id-1'], ['assignee-id-2'], ['alert-id']), + }); + + context.core.elasticsearch.client.asCurrentUser.bulk.mockResponse({ + errors: false, + took: 0, + items: [{ update: { result: 'updated', status: 200, _index: 'test-index' } }], + }); + + const response = await server.inject(request, requestContextMock.convertContext(context)); + + expect(response.status).toEqual(200); + }); + }); + + describe('validation', () => { + test('returns 400 if duplicate assignees are in both the add and remove arrays', async () => { + request = requestMock.create({ + method: 'post', + path: DETECTION_ENGINE_ALERT_ASSIGNEES_URL, + body: getSetAlertAssigneesRequestMock(['assignee-id-1'], ['assignee-id-1'], ['test-id']), + }); + + context.core.elasticsearch.client.asCurrentUser.updateByQuery.mockResponse( + getSuccessfulSignalUpdateResponse() + ); + + const response = await server.inject(request, requestContextMock.convertContext(context)); + + context.core.elasticsearch.client.asCurrentUser.updateByQuery.mockRejectedValue( + new Error('Test error') + ); + + expect(response.body).toEqual({ + message: [ + `Duplicate assignees [\"assignee-id-1\"] were found in the add and remove parameters.`, + ], + status_code: 400, + }); + }); + + test('rejects if no alert ids are provided', async () => { + request = requestMock.create({ + method: 'post', + path: DETECTION_ENGINE_ALERT_ASSIGNEES_URL, + body: getSetAlertAssigneesRequestMock(['assignee-id-1'], ['assignee-id-2']), + }); + + const result = server.validate(request); + + expect(result.badRequest).toHaveBeenCalledWith( + 'ids: Array must contain at least 1 element(s)' + ); + }); + + test('rejects if empty string provided as an alert id', async () => { + request = requestMock.create({ + method: 'post', + path: DETECTION_ENGINE_ALERT_ASSIGNEES_URL, + body: getSetAlertAssigneesRequestMock(['assignee-id-1'], ['assignee-id-2'], ['']), + }); + + const result = server.validate(request); + + expect(result.badRequest).toHaveBeenCalledWith( + 'ids.0: String must contain at least 1 character(s), ids.0: Invalid' + ); + }); + }); + + describe('500s', () => { + test('returns 500 if asCurrentUser throws error', async () => { + request = requestMock.create({ + method: 'post', + path: DETECTION_ENGINE_ALERT_ASSIGNEES_URL, + body: getSetAlertAssigneesRequestMock(['assignee-id-1'], ['assignee-id-2'], ['test-id']), + }); + + context.core.elasticsearch.client.asCurrentUser.updateByQuery.mockRejectedValue( + new Error('Test error') + ); + + const response = await server.inject(request, requestContextMock.convertContext(context)); + + expect(response.body).toEqual({ + message: 'Test error', + status_code: 500, + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_assignees_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_assignees_route.ts new file mode 100644 index 0000000000000..f15342a36f46c --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_assignees_route.ts @@ -0,0 +1,114 @@ +/* + * 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 { transformError } from '@kbn/securitysolution-es-utils'; +import { uniq } from 'lodash/fp'; +import { SetAlertAssigneesRequestBody } from '../../../../../common/api/detection_engine/alert_assignees'; +import type { SecuritySolutionPluginRouter } from '../../../../types'; +import { + DEFAULT_ALERTS_INDEX, + DETECTION_ENGINE_ALERT_ASSIGNEES_URL, +} from '../../../../../common/constants'; +import { buildSiemResponse } from '../utils'; +import { buildRouteValidationWithZod } from '../../../../utils/build_validation/route_validation'; +import { validateAlertAssigneesArrays } from './helpers'; + +export const setAlertAssigneesRoute = (router: SecuritySolutionPluginRouter) => { + router.versioned + .post({ + path: DETECTION_ENGINE_ALERT_ASSIGNEES_URL, + access: 'public', + options: { + tags: ['access:securitySolution'], + }, + }) + .addVersion( + { + version: '2023-10-31', + validate: { + request: { + body: buildRouteValidationWithZod(SetAlertAssigneesRequestBody), + }, + }, + }, + async (context, request, response) => { + const { assignees, ids } = request.body; + const core = await context.core; + const securitySolution = await context.securitySolution; + const esClient = core.elasticsearch.client.asCurrentUser; + const siemResponse = buildSiemResponse(response); + const validationErrors = validateAlertAssigneesArrays(assignees); + const spaceId = securitySolution?.getSpaceId() ?? 'default'; + + if (validationErrors.length) { + return siemResponse.error({ statusCode: 400, body: validationErrors }); + } + + const assigneesToAdd = uniq(assignees.add); + const assigneesToRemove = uniq(assignees.remove); + + const painlessScript = { + params: { assigneesToAdd, assigneesToRemove }, + source: `List newAssigneesArray = []; + if (ctx._source["kibana.alert.workflow_assignee_ids"] != null) { + for (assignee in ctx._source["kibana.alert.workflow_assignee_ids"]) { + if (!params.assigneesToRemove.contains(assignee)) { + newAssigneesArray.add(assignee); + } + } + for (assignee in params.assigneesToAdd) { + if (!newAssigneesArray.contains(assignee)) { + newAssigneesArray.add(assignee) + } + } + ctx._source["kibana.alert.workflow_assignee_ids"] = newAssigneesArray; + } else { + ctx._source["kibana.alert.workflow_assignee_ids"] = params.assigneesToAdd; + } + `, + lang: 'painless', + }; + + const bulkUpdateRequest = []; + for (const id of ids) { + bulkUpdateRequest.push( + { + update: { + _index: `${DEFAULT_ALERTS_INDEX}-${spaceId}`, + _id: id, + }, + }, + { + script: painlessScript, + } + ); + } + + try { + const body = await esClient.updateByQuery({ + index: `${DEFAULT_ALERTS_INDEX}-${spaceId}`, + refresh: true, + body: { + script: painlessScript, + query: { + bool: { + filter: { terms: { _id: ids } }, + }, + }, + }, + }); + return response.ok({ body }); + } catch (err) { + const error = transformError(err); + return siemResponse.error({ + body: error.message, + statusCode: error.statusCode, + }); + } + } + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/translations.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/translations.ts index 715537fee47ab..e6b4e25e641ef 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/translations.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/translations.ts @@ -20,3 +20,10 @@ export const NO_IDS_VALIDATION_ERROR = i18n.translate( defaultMessage: 'No alert ids were provided', } ); + +export const ALERT_ASSIGNEES_VALIDATION_ERROR = (duplicates: string) => + i18n.translate('xpack.securitySolution.api.alertAssignees.validationError', { + values: { duplicates }, + defaultMessage: + 'Duplicate assignees { duplicates } were found in the add and remove parameters.', + }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/users/suggest_user_profiles_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/users/suggest_user_profiles_route.test.ts new file mode 100644 index 0000000000000..bd36547a5c964 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/users/suggest_user_profiles_route.test.ts @@ -0,0 +1,65 @@ +/* + * 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 { securityMock } from '@kbn/security-plugin/server/mocks'; + +import { DETECTION_ENGINE_ALERT_ASSIGNEES_URL } from '../../../../../common/constants'; +import { requestContextMock, serverMock, requestMock } from '../__mocks__'; +import { getMockUserProfiles } from '../__mocks__/request_responses'; +import { suggestUserProfilesRoute } from './suggest_user_profiles_route'; + +describe('suggestUserProfilesRoute', () => { + let server: ReturnType; + let { context } = requestContextMock.createTools(); + let mockSecurityStart: ReturnType; + let getStartServicesMock: jest.Mock; + + beforeEach(() => { + server = serverMock.create(); + ({ context } = requestContextMock.createTools()); + mockSecurityStart = securityMock.createStart(); + mockSecurityStart.userProfiles.suggest.mockResolvedValue(getMockUserProfiles()); + }); + + const buildRequest = () => { + return requestMock.create({ + method: 'get', + path: DETECTION_ENGINE_ALERT_ASSIGNEES_URL, + body: { searchTerm: '' }, + }); + }; + + describe('normal status codes', () => { + beforeEach(() => { + getStartServicesMock = jest.fn().mockResolvedValue([{}, { security: mockSecurityStart }]); + suggestUserProfilesRoute(server.router, getStartServicesMock); + }); + + it('returns 200 when doing a normal request', async () => { + const request = buildRequest(); + const response = await server.inject(request, requestContextMock.convertContext(context)); + expect(response.status).toEqual(200); + }); + + test('returns the payload when doing a normal request', async () => { + const request = buildRequest(); + const response = await server.inject(request, requestContextMock.convertContext(context)); + const expectedBody = getMockUserProfiles(); + expect(response.status).toEqual(200); + expect(response.body).toEqual(expectedBody); + }); + + test('returns 500 if `security.userProfiles.suggest` throws error', async () => { + mockSecurityStart.userProfiles.suggest.mockRejectedValue(new Error('something went wrong')); + const request = buildRequest(); + const response = await server.inject(request, requestContextMock.convertContext(context)); + + expect(response.status).toEqual(500); + expect(response.body.message).toEqual('something went wrong'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/users/suggest_user_profiles_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/users/suggest_user_profiles_route.ts new file mode 100644 index 0000000000000..fcb42d2ead7e4 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/users/suggest_user_profiles_route.ts @@ -0,0 +1,68 @@ +/* + * 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 { IKibanaResponse, StartServicesAccessor } from '@kbn/core/server'; +import { transformError } from '@kbn/securitysolution-es-utils'; +import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; +import type { SecuritySolutionPluginRouter } from '../../../../types'; +import { DETECTION_ENGINE_ALERT_SUGGEST_USERS_URL } from '../../../../../common/constants'; +import { buildSiemResponse } from '../utils'; +import type { StartPlugins } from '../../../../plugin'; +import { buildRouteValidationWithZod } from '../../../../utils/build_validation/route_validation'; +import { SuggestUserProfilesRequestQuery } from '../../../../../common/api/detection_engine/users'; + +export const suggestUserProfilesRoute = ( + router: SecuritySolutionPluginRouter, + getStartServices: StartServicesAccessor +) => { + router.versioned + .get({ + path: DETECTION_ENGINE_ALERT_SUGGEST_USERS_URL, + access: 'public', + options: { + tags: ['access:securitySolution'], + }, + }) + .addVersion( + { + version: '2023-10-31', + validate: { + request: { + query: buildRouteValidationWithZod(SuggestUserProfilesRequestQuery), + }, + }, + }, + async (context, request, response): Promise> => { + const { searchTerm } = request.query; + const siemResponse = buildSiemResponse(response); + const [_, { security }] = await getStartServices(); + const securitySolution = await context.securitySolution; + const spaceId = securitySolution.getSpaceId(); + + try { + const users = await security.userProfiles.suggest({ + name: searchTerm, + dataPath: 'avatar', + requiredPrivileges: { + spaceId, + privileges: { + kibana: [security.authz.actions.login], + }, + }, + }); + + return response.ok({ body: users }); + } catch (err) { + const error = transformError(err); + return siemResponse.error({ + body: error.message, + statusCode: error.statusCode, + }); + } + } + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/es_results.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/es_results.ts index 6a522193558aa..8d134ad215396 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/es_results.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/es_results.ts @@ -52,6 +52,7 @@ import { ALERT_STATUS_ACTIVE, ALERT_URL, ALERT_UUID, + ALERT_WORKFLOW_ASSIGNEE_IDS, ALERT_WORKFLOW_STATUS, ALERT_WORKFLOW_TAGS, EVENT_KIND, @@ -322,6 +323,7 @@ export const sampleAlertDocAADNoSortId = ( }, [ALERT_URL]: 'http://example.com/docID', [ALERT_WORKFLOW_TAGS]: [], + [ALERT_WORKFLOW_ASSIGNEE_IDS]: [], }, fields: { someKey: ['someValue'], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.test.ts index a9ae0d1d55696..4cf64c60de22e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.test.ts @@ -19,6 +19,7 @@ import { ALERT_STATUS_ACTIVE, ALERT_URL, ALERT_UUID, + ALERT_WORKFLOW_ASSIGNEE_IDS, ALERT_WORKFLOW_STATUS, ALERT_WORKFLOW_TAGS, EVENT_ACTION, @@ -233,6 +234,7 @@ describe('buildAlert', () => { [ALERT_URL]: expectedAlertUrl, [ALERT_UUID]: alertUuid, [ALERT_WORKFLOW_TAGS]: [], + [ALERT_WORKFLOW_ASSIGNEE_IDS]: [], }; expect(alert).toEqual(expected); }); @@ -426,6 +428,7 @@ describe('buildAlert', () => { [ALERT_URL]: expectedAlertUrl, [ALERT_UUID]: alertUuid, [ALERT_WORKFLOW_TAGS]: [], + [ALERT_WORKFLOW_ASSIGNEE_IDS]: [], }; expect(alert).toEqual(expected); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.ts index 7641c71b28dbf..846c714a9c099 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.ts @@ -36,6 +36,7 @@ import { ALERT_STATUS_ACTIVE, ALERT_URL, ALERT_UUID, + ALERT_WORKFLOW_ASSIGNEE_IDS, ALERT_WORKFLOW_STATUS, ALERT_WORKFLOW_TAGS, EVENT_KIND, @@ -249,6 +250,7 @@ export const buildAlert = ( [ALERT_URL]: alertUrl, [ALERT_UUID]: alertUuid, [ALERT_WORKFLOW_TAGS]: [], + [ALERT_WORKFLOW_ASSIGNEE_IDS]: [], ...flattenWithPrefix(ALERT_RULE_META, params.meta), // These fields don't exist in the mappings, but leaving here for now to limit changes to the alert building logic 'kibana.alert.rule.risk_score': params.riskScore, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/enrichments/__mocks__/alerts.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/enrichments/__mocks__/alerts.ts index 9ffdc8eafd7f9..e19e7ad1bc0ee 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/enrichments/__mocks__/alerts.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/enrichments/__mocks__/alerts.ts @@ -40,6 +40,7 @@ import { ALERT_STATUS_ACTIVE, ALERT_URL, ALERT_UUID, + ALERT_WORKFLOW_ASSIGNEE_IDS, ALERT_WORKFLOW_STATUS, ALERT_WORKFLOW_TAGS, EVENT_KIND, @@ -96,6 +97,7 @@ export const createAlert = ( [ALERT_STATUS]: ALERT_STATUS_ACTIVE, [ALERT_WORKFLOW_STATUS]: 'open', [ALERT_WORKFLOW_TAGS]: [], + [ALERT_WORKFLOW_ASSIGNEE_IDS]: [], [ALERT_DEPTH]: 1, [ALERT_REASON]: 'reasonable reason', [ALERT_SEVERITY]: 'high', diff --git a/x-pack/plugins/security_solution/server/routes/index.ts b/x-pack/plugins/security_solution/server/routes/index.ts index a6db255d0d4f6..afe48a8d88567 100644 --- a/x-pack/plugins/security_solution/server/routes/index.ts +++ b/x-pack/plugins/security_solution/server/routes/index.ts @@ -56,6 +56,8 @@ import { registerManageExceptionsRoutes } from '../lib/exceptions/api/register_r import { registerDashboardsRoutes } from '../lib/dashboards/routes'; import { registerTagsRoutes } from '../lib/tags/routes'; import { setAlertTagsRoute } from '../lib/detection_engine/routes/signals/set_alert_tags_route'; +import { setAlertAssigneesRoute } from '../lib/detection_engine/routes/signals/set_alert_assignees_route'; +import { suggestUserProfilesRoute } from '../lib/detection_engine/routes/users/suggest_user_profiles_route'; import { riskEngineDisableRoute, riskEngineInitRoute, @@ -72,6 +74,7 @@ import { assetCriticalityGetRoute, assetCriticalityDeleteRoute, } from '../lib/entity_analytics/asset_criticality/routes'; + export const initRoutes = ( router: SecuritySolutionPluginRouter, config: ConfigType, @@ -116,11 +119,13 @@ export const initRoutes = ( // Example usage can be found in security_solution/server/lib/detection_engine/scripts/signals setSignalsStatusRoute(router, logger, security, telemetrySender); setAlertTagsRoute(router); + setAlertAssigneesRoute(router); querySignalsRoute(router, ruleDataClient); getSignalsMigrationStatusRoute(router); createSignalsMigrationRoute(router, security); finalizeSignalsMigrationRoute(router, ruleDataService, security); deleteSignalsMigrationRoute(router, security); + suggestUserProfilesRoute(router, getStartServices); // Detection Engine index routes that have the REST endpoints of /api/detection_engine/index // All REST index creation, policy management for spaces diff --git a/x-pack/plugins/security_solution_ess/public/upselling/register_upsellings.tsx b/x-pack/plugins/security_solution_ess/public/upselling/register_upsellings.tsx index 05af48c280395..41cd10e5e3604 100644 --- a/x-pack/plugins/security_solution_ess/public/upselling/register_upsellings.tsx +++ b/x-pack/plugins/security_solution_ess/public/upselling/register_upsellings.tsx @@ -16,7 +16,10 @@ import type { } from '@kbn/security-solution-upselling/service'; import type { ILicense, LicenseType } from '@kbn/licensing-plugin/public'; import React, { lazy } from 'react'; -import { UPGRADE_INVESTIGATION_GUIDE } from '@kbn/security-solution-upselling/messages'; +import { + UPGRADE_ALERT_ASSIGNMENTS, + UPGRADE_INVESTIGATION_GUIDE, +} from '@kbn/security-solution-upselling/messages'; import type { Services } from '../common/services'; import { withServicesProvider } from '../common/services'; const EntityAnalyticsUpsellingLazy = lazy( @@ -107,4 +110,9 @@ export const upsellingMessages: UpsellingMessages = [ minimumLicenseRequired: 'platinum', message: UPGRADE_INVESTIGATION_GUIDE('Platinum'), }, + { + id: 'alert_assignments', + minimumLicenseRequired: 'platinum', + message: UPGRADE_ALERT_ASSIGNMENTS('Platinum'), + }, ]; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.tsx index 2c98853b3c4e6..4d46f8b4741cc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.tsx @@ -159,6 +159,11 @@ const AlertsTable: React.FunctionComponent = (props: AlertsTab options: props.alertsTableConfiguration.useActionsColumn, }); + const renderCellContext = props.alertsTableConfiguration.useFetchPageContext?.({ + alerts, + columns: props.columns, + }); + const { isBulkActionsColumnActive, getBulkActionsLeadingControlColumn, @@ -373,9 +378,10 @@ const AlertsTable: React.FunctionComponent = (props: AlertsTab props.alertsTableConfiguration?.getRenderCellValue ? props.alertsTableConfiguration?.getRenderCellValue({ setFlyoutAlert: handleFlyoutAlert, + context: renderCellContext, }) : basicRenderCellValue, - [handleFlyoutAlert, props.alertsTableConfiguration] + [handleFlyoutAlert, props.alertsTableConfiguration, renderCellContext] )(); const handleRenderCellValue = useCallback( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/bulk_actions/bulk_actions.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/bulk_actions/bulk_actions.test.tsx index 71bd02d60126f..7ba49502a38e8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/bulk_actions/bulk_actions.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/bulk_actions/bulk_actions.test.tsx @@ -392,6 +392,10 @@ describe('AlertsTable.BulkActions', () => { field: 'kibana.alert.workflow_tags', value: [], }, + { + field: 'kibana.alert.workflow_assignee_ids', + value: [], + }, ], ecs: { _id: 'alert0', @@ -639,6 +643,10 @@ describe('AlertsTable.BulkActions', () => { field: 'kibana.alert.workflow_tags', value: [], }, + { + field: 'kibana.alert.workflow_assignee_ids', + value: [], + }, ], ecs: { _id: 'alert1', @@ -867,6 +875,10 @@ describe('AlertsTable.BulkActions', () => { field: 'kibana.alert.workflow_tags', value: [], }, + { + field: 'kibana.alert.workflow_assignee_ids', + value: [], + }, ], ecs: { _id: 'alert0', @@ -893,6 +905,10 @@ describe('AlertsTable.BulkActions', () => { field: 'kibana.alert.workflow_tags', value: [], }, + { + field: 'kibana.alert.workflow_assignee_ids', + value: [], + }, ], ecs: { _id: 'alert1', diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/bulk_actions/components/toolbar.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/bulk_actions/components/toolbar.tsx index f75dbc43c1fe0..ef3ba30e12082 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/bulk_actions/components/toolbar.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/bulk_actions/components/toolbar.tsx @@ -13,6 +13,7 @@ import { ALERT_CASE_IDS, ALERT_RULE_NAME, ALERT_RULE_UUID, + ALERT_WORKFLOW_ASSIGNEE_IDS, ALERT_WORKFLOW_TAGS, } from '@kbn/rule-data-utils'; import { @@ -64,6 +65,7 @@ const selectedIdsToTimelineItemMapper = ( { field: ALERT_RULE_UUID, value: alert[ALERT_RULE_UUID] }, { field: ALERT_CASE_IDS, value: alert[ALERT_CASE_IDS] ?? [] }, { field: ALERT_WORKFLOW_TAGS, value: alert[ALERT_WORKFLOW_TAGS] ?? [] }, + { field: ALERT_WORKFLOW_ASSIGNEE_IDS, value: alert[ALERT_WORKFLOW_ASSIGNEE_IDS] ?? [] }, ], ecs: { _id: alert._id, diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index bc0ce10d461e6..14be6fea570cb 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -575,12 +575,22 @@ export type AlertsTableProps = { } & Partial>; // TODO We need to create generic type between our plugin, right now we have different one because of the old alerts table -export type GetRenderCellValue = ({ +export type GetRenderCellValue = ({ setFlyoutAlert, + context, }: { setFlyoutAlert?: (data: unknown) => void; + context?: T; }) => (props: unknown) => React.ReactNode; +export type PreFetchPageContext = ({ + alerts, + columns, +}: { + alerts: Alerts; + columns: EuiDataGridColumn[]; +}) => T; + export type AlertTableFlyoutComponent = | React.FunctionComponent | React.LazyExoticComponent> @@ -699,6 +709,7 @@ export interface AlertsTableConfigurationRegistry { }; useFieldBrowserOptions?: UseFieldBrowserOptions; showInspectButton?: boolean; + useFetchPageContext?: PreFetchPageContext; } export interface AlertsTableConfigurationRegistryWithActions diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/alerts_compatibility.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/alerts_compatibility.ts index 7df54659da8ce..1194a1f8df867 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/alerts_compatibility.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/alerts_compatibility.ts @@ -320,6 +320,7 @@ export default ({ getService }: FtrProviderContext) => { 'kibana.alert.status': 'active', 'kibana.alert.workflow_status': 'open', 'kibana.alert.workflow_tags': [], + 'kibana.alert.workflow_assignee_ids': [], 'kibana.alert.depth': 2, 'kibana.alert.reason': 'event on security-linux-1 created high alert Alert Testing Query.', @@ -482,6 +483,7 @@ export default ({ getService }: FtrProviderContext) => { 'kibana.alert.status': 'active', 'kibana.alert.workflow_status': 'open', 'kibana.alert.workflow_tags': [], + 'kibana.alert.workflow_assignee_ids': [], 'kibana.alert.depth': 2, 'kibana.alert.reason': 'event on security-linux-1 created high alert Alert Testing Query.', diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/assignments/assignments.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/assignments/assignments.ts new file mode 100644 index 0000000000000..b520b505e0405 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/assignments/assignments.ts @@ -0,0 +1,519 @@ +/* + * 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 * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; + +import { + DETECTION_ENGINE_ALERT_ASSIGNEES_URL, + DETECTION_ENGINE_QUERY_SIGNALS_URL, +} from '@kbn/security-solution-plugin/common/constants'; +import { DetectionAlert } from '@kbn/security-solution-plugin/common/api/detection_engine'; + +import { + createAlertsIndex, + createRule, + deleteAllAlerts, + deleteAllRules, + getAlertsByIds, + getQueryAlertIds, + getRuleForAlertTesting, + setAlertAssignees, + waitForAlertsToBePresent, + waitForRuleSuccess, +} from '../../../utils'; +import { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { EsArchivePathBuilder } from '../../../../../es_archive_path_builder'; + +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const log = getService('log'); + const es = getService('es'); + const config = getService('config'); + const isServerless = config.get('serverless'); + const dataPathBuilder = new EsArchivePathBuilder(isServerless); + const path = dataPathBuilder.getPath('auditbeat/hosts'); + + describe('@ess @serverless Alert User Assignment - ESS & Serverless', () => { + describe('validation checks', () => { + it('should give errors when no alert ids are provided', async () => { + const { body } = await supertest + .post(DETECTION_ENGINE_ALERT_ASSIGNEES_URL) + .set('kbn-xsrf', 'true') + .send(setAlertAssignees({ assigneesToAdd: [], assigneesToRemove: [], ids: [] })) + .expect(400); + + expect(body).to.eql({ + error: 'Bad Request', + message: '[request body]: ids: Array must contain at least 1 element(s)', + statusCode: 400, + }); + }); + + it('should give errors when empty alert ids are provided', async () => { + const { body } = await supertest + .post(DETECTION_ENGINE_ALERT_ASSIGNEES_URL) + .set('kbn-xsrf', 'true') + .send(setAlertAssignees({ assigneesToAdd: [], assigneesToRemove: [], ids: ['123', ''] })) + .expect(400); + + expect(body).to.eql({ + error: 'Bad Request', + message: + '[request body]: ids.1: String must contain at least 1 character(s), ids.1: Invalid', + statusCode: 400, + }); + }); + + it('should give errors when duplicate assignees exist in both add and remove', async () => { + const { body } = await supertest + .post(DETECTION_ENGINE_ALERT_ASSIGNEES_URL) + .set('kbn-xsrf', 'true') + .send( + setAlertAssignees({ + assigneesToAdd: ['test-1'], + assigneesToRemove: ['test-1'], + ids: ['123'], + }) + ) + .expect(400); + + expect(body).to.eql({ + message: ['Duplicate assignees ["test-1"] were found in the add and remove parameters.'], + status_code: 400, + }); + }); + }); + + describe('tests with auditbeat data', () => { + before(async () => { + await esArchiver.load(path); + }); + + after(async () => { + await esArchiver.unload(path); + }); + + beforeEach(async () => { + await deleteAllRules(supertest, log); + await createAlertsIndex(supertest, log); + }); + + afterEach(async () => { + await deleteAllAlerts(supertest, log, es); + }); + + describe('updating assignees', () => { + it('should add new assignees to single alert', async () => { + const rule = { + ...getRuleForAlertTesting(['auditbeat-*']), + query: 'process.executable: "/usr/bin/sudo"', + }; + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccess({ supertest, log, id }); + await waitForAlertsToBePresent(supertest, log, 10, [id]); + const alerts = await getAlertsByIds(supertest, log, [id]); + const alertIds = alerts.hits.hits.map((alert) => alert._id); + const alertId = alertIds[0]; + + await supertest + .post(DETECTION_ENGINE_ALERT_ASSIGNEES_URL) + .set('kbn-xsrf', 'true') + .send( + setAlertAssignees({ + assigneesToAdd: ['user-1'], + assigneesToRemove: [], + ids: [alertId], + }) + ) + .expect(200); + + const { body }: { body: estypes.SearchResponse } = await supertest + .post(DETECTION_ENGINE_QUERY_SIGNALS_URL) + .set('kbn-xsrf', 'true') + .send(getQueryAlertIds([alertId])) + .expect(200); + + body.hits.hits.map((alert) => { + expect(alert._source?.['kibana.alert.workflow_assignee_ids']).to.eql(['user-1']); + }); + }); + + it('should add new assignees to multiple alerts', async () => { + const rule = { + ...getRuleForAlertTesting(['auditbeat-*']), + query: 'process.executable: "/usr/bin/sudo"', + }; + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccess({ supertest, log, id }); + await waitForAlertsToBePresent(supertest, log, 10, [id]); + const alerts = await getAlertsByIds(supertest, log, [id]); + const alertIds = alerts.hits.hits.map((alert) => alert._id); + + await supertest + .post(DETECTION_ENGINE_ALERT_ASSIGNEES_URL) + .set('kbn-xsrf', 'true') + .send( + setAlertAssignees({ + assigneesToAdd: ['user-2', 'user-3'], + assigneesToRemove: [], + ids: alertIds, + }) + ) + .expect(200); + + const { body }: { body: estypes.SearchResponse } = await supertest + .post(DETECTION_ENGINE_QUERY_SIGNALS_URL) + .set('kbn-xsrf', 'true') + .send(getQueryAlertIds(alertIds)) + .expect(200); + + body.hits.hits.map((alert) => { + expect(alert._source?.['kibana.alert.workflow_assignee_ids']).to.eql([ + 'user-2', + 'user-3', + ]); + }); + }); + + it('should update assignees for single alert', async () => { + const rule = { + ...getRuleForAlertTesting(['auditbeat-*']), + query: 'process.executable: "/usr/bin/sudo"', + }; + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccess({ supertest, log, id }); + await waitForAlertsToBePresent(supertest, log, 10, [id]); + const alerts = await getAlertsByIds(supertest, log, [id]); + const alertIds = alerts.hits.hits.map((alert) => alert._id); + const alertId = alertIds[0]; + + // Assign users + await supertest + .post(DETECTION_ENGINE_ALERT_ASSIGNEES_URL) + .set('kbn-xsrf', 'true') + .send( + setAlertAssignees({ + assigneesToAdd: ['user-1', 'user-2'], + assigneesToRemove: [], + ids: [alertId], + }) + ) + .expect(200); + + // Update assignees + await supertest + .post(DETECTION_ENGINE_ALERT_ASSIGNEES_URL) + .set('kbn-xsrf', 'true') + .send( + setAlertAssignees({ + assigneesToAdd: ['user-3'], + assigneesToRemove: ['user-2'], + ids: [alertId], + }) + ) + .expect(200); + + const { body }: { body: estypes.SearchResponse } = await supertest + .post(DETECTION_ENGINE_QUERY_SIGNALS_URL) + .set('kbn-xsrf', 'true') + .send(getQueryAlertIds([alertId])) + .expect(200); + + body.hits.hits.map((alert) => { + expect(alert._source?.['kibana.alert.workflow_assignee_ids']).to.eql([ + 'user-1', + 'user-3', + ]); + }); + }); + + it('should update assignees for multiple alerts', async () => { + const rule = { + ...getRuleForAlertTesting(['auditbeat-*']), + query: 'process.executable: "/usr/bin/sudo"', + }; + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccess({ supertest, log, id }); + await waitForAlertsToBePresent(supertest, log, 10, [id]); + const alerts = await getAlertsByIds(supertest, log, [id]); + const alertIds = alerts.hits.hits.map((alert) => alert._id); + + // Assign users + await supertest + .post(DETECTION_ENGINE_ALERT_ASSIGNEES_URL) + .set('kbn-xsrf', 'true') + .send( + setAlertAssignees({ + assigneesToAdd: ['user-1', 'user-2'], + assigneesToRemove: [], + ids: alertIds, + }) + ) + .expect(200); + + // Update assignees + await supertest + .post(DETECTION_ENGINE_ALERT_ASSIGNEES_URL) + .set('kbn-xsrf', 'true') + .send( + setAlertAssignees({ + assigneesToAdd: ['user-3'], + assigneesToRemove: ['user-2'], + ids: alertIds, + }) + ) + .expect(200); + + const { body }: { body: estypes.SearchResponse } = await supertest + .post(DETECTION_ENGINE_QUERY_SIGNALS_URL) + .set('kbn-xsrf', 'true') + .send(getQueryAlertIds(alertIds)) + .expect(200); + + body.hits.hits.map((alert) => { + expect(alert._source?.['kibana.alert.workflow_assignee_ids']).to.eql([ + 'user-1', + 'user-3', + ]); + }); + }); + + it('should add assignee once to the alert even if same assignee was passed multiple times', async () => { + const rule = { + ...getRuleForAlertTesting(['auditbeat-*']), + query: 'process.executable: "/usr/bin/sudo"', + }; + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccess({ supertest, log, id }); + await waitForAlertsToBePresent(supertest, log, 10, [id]); + const alerts = await getAlertsByIds(supertest, log, [id]); + const alertIds = alerts.hits.hits.map((alert) => alert._id); + + await supertest + .post(DETECTION_ENGINE_ALERT_ASSIGNEES_URL) + .set('kbn-xsrf', 'true') + .send( + setAlertAssignees({ + assigneesToAdd: ['user-1', 'user-1', 'user-1', 'user-2'], + assigneesToRemove: [], + ids: alertIds, + }) + ) + .expect(200); + + const { body }: { body: estypes.SearchResponse } = await supertest + .post(DETECTION_ENGINE_QUERY_SIGNALS_URL) + .set('kbn-xsrf', 'true') + .send(getQueryAlertIds(alertIds)) + .expect(200); + + body.hits.hits.map((alert) => { + expect(alert._source?.['kibana.alert.workflow_assignee_ids']).to.eql([ + 'user-1', + 'user-2', + ]); + }); + }); + + it('should remove assignee once to the alert even if same assignee was passed multiple times', async () => { + const rule = { + ...getRuleForAlertTesting(['auditbeat-*']), + query: 'process.executable: "/usr/bin/sudo"', + }; + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccess({ supertest, log, id }); + await waitForAlertsToBePresent(supertest, log, 10, [id]); + const alerts = await getAlertsByIds(supertest, log, [id]); + const alertIds = alerts.hits.hits.map((alert) => alert._id); + + await supertest + .post(DETECTION_ENGINE_ALERT_ASSIGNEES_URL) + .set('kbn-xsrf', 'true') + .send( + setAlertAssignees({ + assigneesToAdd: ['user-1', 'user-2'], + assigneesToRemove: [], + ids: alertIds, + }) + ) + .expect(200); + + await supertest + .post(DETECTION_ENGINE_ALERT_ASSIGNEES_URL) + .set('kbn-xsrf', 'true') + .send( + setAlertAssignees({ + assigneesToAdd: [], + assigneesToRemove: ['user-2', 'user-2'], + ids: alertIds, + }) + ) + .expect(200); + + const { body }: { body: estypes.SearchResponse } = await supertest + .post(DETECTION_ENGINE_QUERY_SIGNALS_URL) + .set('kbn-xsrf', 'true') + .send(getQueryAlertIds(alertIds)) + .expect(200); + + body.hits.hits.map((alert) => { + expect(alert._source?.['kibana.alert.workflow_assignee_ids']).to.eql(['user-1']); + }); + }); + + it('should not update assignees if both `add` and `remove` are empty', async () => { + const rule = { + ...getRuleForAlertTesting(['auditbeat-*']), + query: 'process.executable: "/usr/bin/sudo"', + }; + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccess({ supertest, log, id }); + await waitForAlertsToBePresent(supertest, log, 10, [id]); + const alerts = await getAlertsByIds(supertest, log, [id]); + const alertIds = alerts.hits.hits.map((alert) => alert._id); + + await supertest + .post(DETECTION_ENGINE_ALERT_ASSIGNEES_URL) + .set('kbn-xsrf', 'true') + .send( + setAlertAssignees({ + assigneesToAdd: ['user-1', 'user-2'], + assigneesToRemove: [], + ids: alertIds, + }) + ) + .expect(200); + + await supertest + .post(DETECTION_ENGINE_ALERT_ASSIGNEES_URL) + .set('kbn-xsrf', 'true') + .send( + setAlertAssignees({ + assigneesToAdd: [], + assigneesToRemove: [], + ids: alertIds, + }) + ) + .expect(200); + + const { body }: { body: estypes.SearchResponse } = await supertest + .post(DETECTION_ENGINE_QUERY_SIGNALS_URL) + .set('kbn-xsrf', 'true') + .send(getQueryAlertIds(alertIds)) + .expect(200); + + body.hits.hits.map((alert) => { + expect(alert._source?.['kibana.alert.workflow_assignee_ids']).to.eql([ + 'user-1', + 'user-2', + ]); + }); + }); + + it('should not update assignees when adding user which is assigned to alert', async () => { + const rule = { + ...getRuleForAlertTesting(['auditbeat-*']), + query: 'process.executable: "/usr/bin/sudo"', + }; + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccess({ supertest, log, id }); + await waitForAlertsToBePresent(supertest, log, 10, [id]); + const alerts = await getAlertsByIds(supertest, log, [id]); + const alertIds = alerts.hits.hits.map((alert) => alert._id); + + await supertest + .post(DETECTION_ENGINE_ALERT_ASSIGNEES_URL) + .set('kbn-xsrf', 'true') + .send( + setAlertAssignees({ + assigneesToAdd: ['user-1', 'user-2'], + assigneesToRemove: [], + ids: alertIds, + }) + ) + .expect(200); + + await supertest + .post(DETECTION_ENGINE_ALERT_ASSIGNEES_URL) + .set('kbn-xsrf', 'true') + .send( + setAlertAssignees({ + assigneesToAdd: ['user-2'], + assigneesToRemove: [], + ids: alertIds, + }) + ) + .expect(200); + + const { body }: { body: estypes.SearchResponse } = await supertest + .post(DETECTION_ENGINE_QUERY_SIGNALS_URL) + .set('kbn-xsrf', 'true') + .send(getQueryAlertIds(alertIds)) + .expect(200); + + body.hits.hits.map((alert) => { + expect(alert._source?.['kibana.alert.workflow_assignee_ids']).to.eql([ + 'user-1', + 'user-2', + ]); + }); + }); + + it('should not update assignees when removing user which is not assigned to alert', async () => { + const rule = { + ...getRuleForAlertTesting(['auditbeat-*']), + query: 'process.executable: "/usr/bin/sudo"', + }; + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccess({ supertest, log, id }); + await waitForAlertsToBePresent(supertest, log, 10, [id]); + const alerts = await getAlertsByIds(supertest, log, [id]); + const alertIds = alerts.hits.hits.map((alert) => alert._id); + + await supertest + .post(DETECTION_ENGINE_ALERT_ASSIGNEES_URL) + .set('kbn-xsrf', 'true') + .send( + setAlertAssignees({ + assigneesToAdd: ['user-1', 'user-2'], + assigneesToRemove: [], + ids: alertIds, + }) + ) + .expect(200); + + await supertest + .post(DETECTION_ENGINE_ALERT_ASSIGNEES_URL) + .set('kbn-xsrf', 'true') + .send( + setAlertAssignees({ + assigneesToAdd: [], + assigneesToRemove: ['user-3'], + ids: alertIds, + }) + ) + .expect(200); + + const { body }: { body: estypes.SearchResponse } = await supertest + .post(DETECTION_ENGINE_QUERY_SIGNALS_URL) + .set('kbn-xsrf', 'true') + .send(getQueryAlertIds(alertIds)) + .expect(200); + + body.hits.hits.map((alert) => { + expect(alert._source?.['kibana.alert.workflow_assignee_ids']).to.eql([ + 'user-1', + 'user-2', + ]); + }); + }); + }); + }); + }); +}; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/assignments/assignments_ess.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/assignments/assignments_ess.ts new file mode 100644 index 0000000000000..527d02295f6a0 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/assignments/assignments_ess.ts @@ -0,0 +1,92 @@ +/* + * 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 { DETECTION_ENGINE_ALERT_ASSIGNEES_URL } from '@kbn/security-solution-plugin/common/constants'; +import { ROLES } from '@kbn/security-solution-plugin/common/test'; + +import { + createUserAndRole, + deleteUserAndRole, +} from '../../../../../../common/services/security_solution'; +import { + createAlertsIndex, + createRule, + deleteAllAlerts, + deleteAllRules, + getAlertsByIds, + getRuleForAlertTesting, + setAlertAssignees, + waitForAlertsToBePresent, + waitForRuleSuccess, +} from '../../../utils'; +import { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { EsArchivePathBuilder } from '../../../../../es_archive_path_builder'; + +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + const log = getService('log'); + const es = getService('es'); + const config = getService('config'); + const isServerless = config.get('serverless'); + const dataPathBuilder = new EsArchivePathBuilder(isServerless); + const path = dataPathBuilder.getPath('auditbeat/hosts'); + + describe('@ess Alert User Assignment - ESS', () => { + before(async () => { + await esArchiver.load(path); + }); + + after(async () => { + await esArchiver.unload(path); + }); + + beforeEach(async () => { + await deleteAllRules(supertest, log); + await createAlertsIndex(supertest, log); + }); + + afterEach(async () => { + await deleteAllAlerts(supertest, log, es); + }); + + describe('authorization / RBAC', () => { + it('should not allow viewer user to assign alerts', async () => { + const rule = { + ...getRuleForAlertTesting(['auditbeat-*']), + query: 'process.executable: "/usr/bin/sudo"', + }; + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccess({ supertest, log, id }); + await waitForAlertsToBePresent(supertest, log, 10, [id]); + const alerts = await getAlertsByIds(supertest, log, [id]); + const alertIds = alerts.hits.hits.map((alert) => alert._id); + + const userAndRole = ROLES.reader; + await createUserAndRole(getService, userAndRole); + + // Try to set all of the alerts to the state of closed. + // This should not be possible with the given user. + await supertestWithoutAuth + .post(DETECTION_ENGINE_ALERT_ASSIGNEES_URL) + .set('kbn-xsrf', 'true') + .auth(userAndRole, 'changeme') // each user has the same password + .send( + setAlertAssignees({ + assigneesToAdd: ['user-1'], + assigneesToRemove: [], + ids: alertIds, + }) + ) + .expect(403); + + await deleteUserAndRole(getService, userAndRole); + }); + }); + }); +}; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/assignments/assignments_serverless.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/assignments/assignments_serverless.ts new file mode 100644 index 0000000000000..dd41574c56940 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/assignments/assignments_serverless.ts @@ -0,0 +1,111 @@ +/* + * 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 { DETECTION_ENGINE_ALERT_ASSIGNEES_URL } from '@kbn/security-solution-plugin/common/constants'; +import { ROLES } from '@kbn/security-solution-plugin/common/test'; + +import { + createAlertsIndex, + createRule, + deleteAllAlerts, + deleteAllRules, + getAlertsByIds, + getRuleForAlertTesting, + setAlertAssignees, + waitForAlertsToBePresent, + waitForRuleSuccess, +} from '../../../utils'; +import { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { EsArchivePathBuilder } from '../../../../../es_archive_path_builder'; + +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + const log = getService('log'); + const es = getService('es'); + const config = getService('config'); + const isServerless = config.get('serverless'); + const dataPathBuilder = new EsArchivePathBuilder(isServerless); + const path = dataPathBuilder.getPath('auditbeat/hosts'); + + describe('@serverless Alert User Assignment - Serverless', () => { + before(async () => { + await esArchiver.load(path); + }); + + after(async () => { + await esArchiver.unload(path); + }); + + beforeEach(async () => { + await deleteAllRules(supertest, log); + await createAlertsIndex(supertest, log); + }); + + afterEach(async () => { + await deleteAllAlerts(supertest, log, es); + }); + + describe('authorization / RBAC', () => { + const successfulAssignWithRole = async (userAndRole: ROLES) => { + const rule = { + ...getRuleForAlertTesting(['auditbeat-*']), + query: 'process.executable: "/usr/bin/sudo"', + }; + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccess({ supertest, log, id }); + await waitForAlertsToBePresent(supertest, log, 10, [id]); + const alerts = await getAlertsByIds(supertest, log, [id]); + const alertIds = alerts.hits.hits.map((alert) => alert._id); + + // Try to set all of the alerts to the state of closed. + // This should not be possible with the given user. + await supertestWithoutAuth + .post(DETECTION_ENGINE_ALERT_ASSIGNEES_URL) + .set('kbn-xsrf', 'true') + .auth(userAndRole, 'changeme') // each user has the same password + .send( + setAlertAssignees({ + assigneesToAdd: ['user-1'], + assigneesToRemove: [], + ids: alertIds, + }) + ) + .expect(200); + }; + + it('should allow `ROLES.t1_analyst` to assign alerts', async () => { + await successfulAssignWithRole(ROLES.t1_analyst); + }); + + it('should allow `ROLES.t2_analyst` to assign alerts', async () => { + await successfulAssignWithRole(ROLES.t2_analyst); + }); + + it('should allow `ROLES.t3_analyst` to assign alerts', async () => { + await successfulAssignWithRole(ROLES.t3_analyst); + }); + + it('should allow `ROLES.detections_admin` to assign alerts', async () => { + await successfulAssignWithRole(ROLES.detections_admin); + }); + + it('should allow `ROLES.platform_engineer` to assign alerts', async () => { + await successfulAssignWithRole(ROLES.platform_engineer); + }); + + it('should allow `ROLES.rule_author` to assign alerts', async () => { + await successfulAssignWithRole(ROLES.rule_author); + }); + + it('should allow `ROLES.soc_manager` to assign alerts', async () => { + await successfulAssignWithRole(ROLES.soc_manager); + }); + }); + }); +}; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/assignments/index.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/assignments/index.ts new file mode 100644 index 0000000000000..401f92ea9dcf6 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/assignments/index.ts @@ -0,0 +1,15 @@ +/* + * 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 { FtrProviderContext } from '../../../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('Alert assignments API', function () { + loadTestFile(require.resolve('./assignments')); + loadTestFile(require.resolve('./assignments_ess')); + loadTestFile(require.resolve('./assignments_serverless')); + }); +} diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/index.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/index.ts index 7482e1bac558f..85e2e602a8929 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/index.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/index.ts @@ -14,5 +14,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./migrations')); loadTestFile(require.resolve('./open_close_alerts')); loadTestFile(require.resolve('./set_alert_tags')); + loadTestFile(require.resolve('./assignments')); }); } diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/eql.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/eql.ts index 9cb85e3366cee..db5a924b48a05 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/eql.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/eql.ts @@ -11,6 +11,7 @@ import { ALERT_RULE_UUID, ALERT_WORKFLOW_STATUS, ALERT_WORKFLOW_TAGS, + ALERT_WORKFLOW_ASSIGNEE_IDS, EVENT_KIND, } from '@kbn/rule-data-utils'; import { flattenWithPrefix } from '@kbn/securitysolution-rules'; @@ -155,6 +156,7 @@ export default ({ getService }: FtrProviderContext) => { [ALERT_ORIGINAL_TIME]: fullAlert[ALERT_ORIGINAL_TIME], [ALERT_WORKFLOW_STATUS]: 'open', [ALERT_WORKFLOW_TAGS]: [], + [ALERT_WORKFLOW_ASSIGNEE_IDS]: [], [ALERT_DEPTH]: 1, [ALERT_ANCESTORS]: [ { diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/esql.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/esql.ts index ede46e40254fd..cb0f31ad25460 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/esql.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/esql.ts @@ -151,6 +151,7 @@ export default ({ getService }: FtrProviderContext) => { 'kibana.alert.rule.updated_by': 'elastic', 'kibana.alert.rule.version': 1, 'kibana.alert.workflow_tags': [], + 'kibana.alert.workflow_assignee_ids': [], 'kibana.alert.rule.risk_score': 55, 'kibana.alert.rule.severity': 'high', }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/machine_learning.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/machine_learning.ts index 6426738b61427..d58227377f116 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/machine_learning.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/machine_learning.ts @@ -15,6 +15,7 @@ import { ALERT_UUID, ALERT_WORKFLOW_STATUS, ALERT_WORKFLOW_TAGS, + ALERT_WORKFLOW_ASSIGNEE_IDS, SPACE_IDS, VERSION, } from '@kbn/rule-data-utils'; @@ -125,6 +126,7 @@ export default ({ getService }: FtrProviderContext) => { [ALERT_ANCESTORS]: expect.any(Array), [ALERT_WORKFLOW_STATUS]: 'open', [ALERT_WORKFLOW_TAGS]: [], + [ALERT_WORKFLOW_ASSIGNEE_IDS]: [], [ALERT_STATUS]: 'active', [SPACE_IDS]: ['default'], [ALERT_SEVERITY]: 'critical', diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/new_terms.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/new_terms.ts index 5e7f919520058..8a47aeaa89bdc 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/new_terms.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/new_terms.ts @@ -166,6 +166,7 @@ export default ({ getService }: FtrProviderContext) => { 'kibana.alert.status': 'active', 'kibana.alert.workflow_status': 'open', 'kibana.alert.workflow_tags': [], + 'kibana.alert.workflow_assignee_ids': [], 'kibana.alert.depth': 1, 'kibana.alert.reason': 'authentication event with source 8.42.77.171 by root on zeek-newyork-sha-aa8df15 created high alert Query with a rule id.', diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/threat_match.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/threat_match.ts index 8d7cf8fd9f89b..9b6c525b5e351 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/threat_match.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/threat_match.ts @@ -18,6 +18,7 @@ import { SPACE_IDS, VERSION, ALERT_WORKFLOW_TAGS, + ALERT_WORKFLOW_ASSIGNEE_IDS, } from '@kbn/rule-data-utils'; import { flattenWithPrefix } from '@kbn/securitysolution-rules'; import { ThreatMapping } from '@kbn/securitysolution-io-ts-alerting-types'; @@ -294,6 +295,7 @@ export default ({ getService }: FtrProviderContext) => { [ALERT_UUID]: fullAlert[ALERT_UUID], [ALERT_WORKFLOW_STATUS]: 'open', [ALERT_WORKFLOW_TAGS]: [], + [ALERT_WORKFLOW_ASSIGNEE_IDS]: [], [SPACE_IDS]: ['default'], [VERSION]: fullAlert[VERSION], threat: { diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/alerts/alert_assignees.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/alerts/alert_assignees.ts new file mode 100644 index 0000000000000..59c70d5d6bd9e --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/alerts/alert_assignees.ts @@ -0,0 +1,25 @@ +/* + * 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 { AlertIds } from '@kbn/security-solution-plugin/common/api/detection_engine'; +import { SetAlertAssigneesRequestBody } from '@kbn/security-solution-plugin/common/api/detection_engine'; + +export const setAlertAssignees = ({ + assigneesToAdd, + assigneesToRemove, + ids, +}: { + assigneesToAdd: string[]; + assigneesToRemove: string[]; + ids: AlertIds; +}): SetAlertAssigneesRequestBody => ({ + assignees: { + add: assigneesToAdd, + remove: assigneesToRemove, + }, + ids, +}); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/alerts/index.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/alerts/index.ts index e78bfa1922d36..867f85653ef4f 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/alerts/index.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/alerts/index.ts @@ -21,4 +21,5 @@ export * from './get_query_alert_ids'; export * from './set_alert_tags'; export * from './get_preview_alerts'; export * from './get_alert_status'; +export * from './alert_assignees'; export * from './migrations'; diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/assignments/assignments.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/assignments/assignments.cy.ts new file mode 100644 index 0000000000000..83595c1f81e90 --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/assignments/assignments.cy.ts @@ -0,0 +1,368 @@ +/* + * 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 { ROLES } from '@kbn/security-solution-plugin/common/test'; +import { getNewRule } from '../../../../objects/rule'; +import { + closeAlertFlyout, + closeAlerts, + expandFirstAlert, + selectFirstPageAlerts, + selectNumberOfAlerts, + selectPageFilterValue, +} from '../../../../tasks/alerts'; +import { createRule } from '../../../../tasks/api_calls/rules'; +import { deleteAlertsAndRules } from '../../../../tasks/api_calls/common'; +import { login } from '../../../../tasks/login'; +import { ALERTS_URL } from '../../../../urls/navigation'; +import { waitForAlertsToPopulate } from '../../../../tasks/create_new_rule'; +import { + alertDetailsFlyoutShowsAssignees, + alertDetailsFlyoutShowsAssigneesBadge, + alertsTableShowsAssigneesBadgeForAlert, + alertsTableShowsAssigneesForAlert, + updateAssigneesForAlert, + checkEmptyAssigneesStateInAlertDetailsFlyout, + checkEmptyAssigneesStateInAlertsTable, + removeAllAssigneesForAlert, + bulkUpdateAssignees, + alertsTableShowsAssigneesForAllAlerts, + bulkRemoveAllAssignees, + filterByAssignees, + NO_ASSIGNEES, + clearAssigneesFilter, + updateAssigneesViaAddButtonInFlyout, + updateAssigneesViaTakeActionButtonInFlyout, + removeAllAssigneesViaTakeActionButtonInFlyout, + loadPageAs, +} from '../../../../tasks/alert_assignments'; +import { ALERTS_COUNT } from '../../../../screens/alerts'; + +describe('Alert user assignment - ESS & Serverless', { tags: ['@ess', '@serverless'] }, () => { + before(() => { + cy.task('esArchiverLoad', { archiveName: 'auditbeat_multiple' }); + + // Login into accounts so that they got activated and visible in user profiles list + login(ROLES.t1_analyst); + login(ROLES.t2_analyst); + login(ROLES.t3_analyst); + login(ROLES.soc_manager); + login(ROLES.detections_admin); + login(ROLES.platform_engineer); + }); + + after(() => { + cy.task('esArchiverUnload', 'auditbeat_multiple'); + }); + + beforeEach(() => { + loadPageAs(ALERTS_URL); + deleteAlertsAndRules(); + createRule(getNewRule({ rule_id: 'new custom rule' })); + waitForAlertsToPopulate(); + }); + + context('Basic rendering', () => { + it('alert with no assignees in alerts table', () => { + checkEmptyAssigneesStateInAlertsTable(); + }); + + it(`alert with no assignees in alert's details flyout`, () => { + expandFirstAlert(); + checkEmptyAssigneesStateInAlertDetailsFlyout(); + }); + + it('alert with some assignees in alerts table', () => { + const users = [ROLES.detections_admin, ROLES.t1_analyst]; + updateAssigneesForAlert(users); + alertsTableShowsAssigneesForAlert(users); + }); + + it(`alert with some assignees in alert's details flyout`, () => { + const users = [ROLES.detections_admin, ROLES.t1_analyst]; + updateAssigneesForAlert(users); + expandFirstAlert(); + alertDetailsFlyoutShowsAssignees(users); + }); + + it('alert with many assignees (collapsed into badge) in alerts table', () => { + const users = [ + ROLES.t1_analyst, + ROLES.t2_analyst, + ROLES.t3_analyst, + ROLES.soc_manager, + ROLES.detections_admin, + ]; + updateAssigneesForAlert(users); + alertsTableShowsAssigneesBadgeForAlert(users); + }); + + it(`alert with many assignees (collapsed into badge) in alert's details flyout`, () => { + const users = [ROLES.detections_admin, ROLES.t1_analyst, ROLES.t2_analyst]; + updateAssigneesForAlert(users); + expandFirstAlert(); + alertDetailsFlyoutShowsAssigneesBadge(users); + }); + }); + + context('Updating assignees (single alert)', () => { + it('adding new assignees via `More actions` in alerts table', () => { + // Assign users + const users = [ROLES.detections_admin, ROLES.t1_analyst]; + updateAssigneesForAlert(users); + + // Assignees should appear in the alerts table + alertsTableShowsAssigneesForAlert(users); + + // Assignees should appear in the alert's details flyout + expandFirstAlert(); + alertDetailsFlyoutShowsAssignees(users); + }); + + it('adding new assignees via add button in flyout', () => { + expandFirstAlert(); + + // Assign users + const users = [ROLES.detections_admin, ROLES.t1_analyst]; + updateAssigneesViaAddButtonInFlyout(users); + + // Assignees should appear in the alert's details flyout + alertDetailsFlyoutShowsAssignees(users); + + // Assignees should appear in the alerts table + closeAlertFlyout(); + alertsTableShowsAssigneesForAlert(users); + }); + + it('adding new assignees via `Take action` button in flyout', () => { + expandFirstAlert(); + + // Assign users + const users = [ROLES.detections_admin, ROLES.t1_analyst]; + updateAssigneesViaTakeActionButtonInFlyout(users); + + // Assignees should appear in the alert's details flyout + alertDetailsFlyoutShowsAssignees(users); + + // Assignees should appear in the alerts table + closeAlertFlyout(); + alertsTableShowsAssigneesForAlert(users); + }); + + it('updating assignees via `More actions` in alerts table', () => { + // Initially assigned users + const initialAssignees = [ROLES.detections_admin, ROLES.t1_analyst]; + updateAssigneesForAlert(initialAssignees); + alertsTableShowsAssigneesForAlert(initialAssignees); + + // Update assignees + const updatedAssignees = [ROLES.t1_analyst, ROLES.t2_analyst]; + updateAssigneesForAlert(updatedAssignees); + + const expectedAssignees = [ROLES.detections_admin, ROLES.t2_analyst]; + + // Expected assignees should appear in the alerts table + alertsTableShowsAssigneesForAlert(expectedAssignees); + + // Expected assignees should appear in the alert's details flyout + expandFirstAlert(); + alertDetailsFlyoutShowsAssignees(expectedAssignees); + }); + + it('updating assignees via add button in flyout', () => { + expandFirstAlert(); + + // Initially assigned users + const initialAssignees = [ROLES.detections_admin, ROLES.t1_analyst]; + updateAssigneesViaAddButtonInFlyout(initialAssignees); + alertDetailsFlyoutShowsAssignees(initialAssignees); + + // Update assignees + const updatedAssignees = [ROLES.t1_analyst, ROLES.t2_analyst]; + updateAssigneesViaAddButtonInFlyout(updatedAssignees); + + const expectedAssignees = [ROLES.detections_admin, ROLES.t2_analyst]; + + // Expected assignees should appear in the alert's details flyout + alertDetailsFlyoutShowsAssignees(expectedAssignees); + + // Expected assignees should appear in the alerts table + closeAlertFlyout(); + alertsTableShowsAssigneesForAlert(expectedAssignees); + }); + + it('updating assignees via `Take action` button in flyout', () => { + expandFirstAlert(); + + // Initially assigned users + const initialAssignees = [ROLES.detections_admin, ROLES.t1_analyst]; + updateAssigneesViaTakeActionButtonInFlyout(initialAssignees); + alertDetailsFlyoutShowsAssignees(initialAssignees); + + // Update assignees + const updatedAssignees = [ROLES.t1_analyst, ROLES.t2_analyst]; + updateAssigneesViaTakeActionButtonInFlyout(updatedAssignees); + + const expectedAssignees = [ROLES.detections_admin, ROLES.t2_analyst]; + + // Expected assignees should appear in the alert's details flyout + alertDetailsFlyoutShowsAssignees(expectedAssignees); + + // Expected assignees should appear in the alerts table + closeAlertFlyout(); + alertsTableShowsAssigneesForAlert(expectedAssignees); + }); + + it('removing all assignees via `More actions` in alerts table', () => { + // Initially assigned users + const initialAssignees = [ROLES.detections_admin, ROLES.t1_analyst]; + updateAssigneesForAlert(initialAssignees); + alertsTableShowsAssigneesForAlert(initialAssignees); + + removeAllAssigneesForAlert(); + + // Alert should not show any assignee in alerts table or in details flyout + checkEmptyAssigneesStateInAlertsTable(); + expandFirstAlert(); + checkEmptyAssigneesStateInAlertDetailsFlyout(); + }); + + it('removing all assignees via `Take action` button in flyout', () => { + expandFirstAlert(); + + // Initially assigned users + const initialAssignees = [ROLES.detections_admin, ROLES.t1_analyst]; + updateAssigneesViaTakeActionButtonInFlyout(initialAssignees); + alertDetailsFlyoutShowsAssignees(initialAssignees); + + removeAllAssigneesViaTakeActionButtonInFlyout(); + + // Alert should not show any assignee in alerts table or in details flyout + checkEmptyAssigneesStateInAlertDetailsFlyout(); + closeAlertFlyout(); + checkEmptyAssigneesStateInAlertsTable(); + }); + }); + + context('Updating assignees (bulk actions)', () => { + it('adding new assignees should be reflected in UI (alerts table and details flyout)', () => { + selectFirstPageAlerts(); + + // Assign users + const users = [ROLES.detections_admin, ROLES.t1_analyst]; + bulkUpdateAssignees(users); + + // Assignees should appear in the alerts table + alertsTableShowsAssigneesForAllAlerts(users); + }); + + it('updating assignees should be reflected in UI (alerts table and details flyout)', () => { + selectFirstPageAlerts(); + + // Initially assigned users + const initialAssignees = [ROLES.detections_admin, ROLES.t1_analyst]; + bulkUpdateAssignees(initialAssignees); + alertsTableShowsAssigneesForAllAlerts(initialAssignees); + + // Update assignees + selectFirstPageAlerts(); + const updatedAssignees = [ROLES.t1_analyst, ROLES.t2_analyst]; + bulkUpdateAssignees(updatedAssignees); + + const expectedAssignees = [ROLES.detections_admin, ROLES.t2_analyst]; + + // Expected assignees should appear in the alerts table + alertsTableShowsAssigneesForAllAlerts(expectedAssignees); + }); + + it('removing all assignees should be reflected in UI (alerts table and details flyout)', () => { + selectFirstPageAlerts(); + + // Initially assigned users + const initialAssignees = [ROLES.detections_admin, ROLES.t1_analyst]; + bulkUpdateAssignees(initialAssignees); + alertsTableShowsAssigneesForAllAlerts(initialAssignees); + + // Unassign alert + selectFirstPageAlerts(); + bulkRemoveAllAssignees(); + + // Alerts should not have assignees + checkEmptyAssigneesStateInAlertsTable(); + }); + }); + + context('Alerts filtering', () => { + it('by `No assignees` option', () => { + const totalNumberOfAlerts = 5; + const numberOfSelectedAlerts = 2; + selectNumberOfAlerts(numberOfSelectedAlerts); + bulkUpdateAssignees([ROLES.t1_analyst]); + + filterByAssignees([NO_ASSIGNEES]); + + const expectedNumberOfAlerts = totalNumberOfAlerts - numberOfSelectedAlerts; + cy.get(ALERTS_COUNT).contains(expectedNumberOfAlerts); + }); + + it('by one assignee', () => { + const numberOfSelectedAlerts = 2; + selectNumberOfAlerts(numberOfSelectedAlerts); + bulkUpdateAssignees([ROLES.t1_analyst]); + + filterByAssignees([ROLES.t1_analyst]); + + cy.get(ALERTS_COUNT).contains(numberOfSelectedAlerts); + }); + + it('by multiple assignees', () => { + const numberOfSelectedAlerts1 = 1; + selectNumberOfAlerts(numberOfSelectedAlerts1); + bulkUpdateAssignees([ROLES.t1_analyst]); + + filterByAssignees([NO_ASSIGNEES]); + + const numberOfSelectedAlerts2 = 2; + selectNumberOfAlerts(numberOfSelectedAlerts2); + bulkUpdateAssignees([ROLES.detections_admin]); + + clearAssigneesFilter(); + + cy.get(ALERTS_COUNT).contains(5); + + filterByAssignees([ROLES.t1_analyst, ROLES.detections_admin]); + + const expectedNumberOfAlerts = numberOfSelectedAlerts1 + numberOfSelectedAlerts2; + cy.get(ALERTS_COUNT).contains(expectedNumberOfAlerts); + }); + + it('by assignee and alert status', () => { + const totalNumberOfAlerts = 5; + const numberOfAssignedAlerts = 3; + selectNumberOfAlerts(numberOfAssignedAlerts); + bulkUpdateAssignees([ROLES.t1_analyst]); + + filterByAssignees([ROLES.t1_analyst]); + + const numberOfClosedAlerts = 1; + selectNumberOfAlerts(numberOfClosedAlerts); + closeAlerts(); + + const expectedNumberOfAllerts1 = numberOfAssignedAlerts - numberOfClosedAlerts; + cy.get(ALERTS_COUNT).contains(expectedNumberOfAllerts1); + + clearAssigneesFilter(); + + const expectedNumberOfAllerts2 = totalNumberOfAlerts - numberOfClosedAlerts; + cy.get(ALERTS_COUNT).contains(expectedNumberOfAllerts2); + + filterByAssignees([ROLES.t1_analyst]); + selectPageFilterValue(0, 'closed'); + cy.get(ALERTS_COUNT).contains(numberOfClosedAlerts); + }); + }); +}); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/assignments/assignments_ess.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/assignments/assignments_ess.cy.ts new file mode 100644 index 0000000000000..bdaaedab7f0bf --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/assignments/assignments_ess.cy.ts @@ -0,0 +1,49 @@ +/* + * 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 { ROLES } from '@kbn/security-solution-plugin/common/test'; +import { getNewRule } from '../../../../objects/rule'; +import { expandFirstAlert } from '../../../../tasks/alerts'; +import { createRule } from '../../../../tasks/api_calls/rules'; +import { deleteAlertsAndRules } from '../../../../tasks/api_calls/common'; +import { ALERTS_URL } from '../../../../urls/navigation'; +import { waitForAlertsToPopulate } from '../../../../tasks/create_new_rule'; +import { + alertsTableMoreActionsAreNotAvailable, + cannotAddAssigneesViaDetailsFlyout, + loadPageAs, +} from '../../../../tasks/alert_assignments'; + +describe('Alert user assignment - ESS', { tags: ['@ess'] }, () => { + before(() => { + cy.task('esArchiverLoad', { archiveName: 'auditbeat_multiple' }); + }); + + after(() => { + cy.task('esArchiverUnload', 'auditbeat_multiple'); + }); + + beforeEach(() => { + loadPageAs(ALERTS_URL); + deleteAlertsAndRules(); + createRule(getNewRule({ rule_id: 'new custom rule' })); + waitForAlertsToPopulate(); + }); + + it('viewer/reader should not be able to update assignees', () => { + // Login as a reader + loadPageAs(ALERTS_URL, ROLES.reader); + waitForAlertsToPopulate(); + + // Check alerts table + alertsTableMoreActionsAreNotAvailable(); + + // Check alert's details flyout + expandFirstAlert(); + cannotAddAssigneesViaDetailsFlyout(); + }); +}); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/assignments/assignments_ess_basic.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/assignments/assignments_ess_basic.cy.ts new file mode 100644 index 0000000000000..34bab70e43b0f --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/assignments/assignments_ess_basic.cy.ts @@ -0,0 +1,60 @@ +/* + * 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 { login } from '../../../../tasks/login'; +import { getNewRule } from '../../../../objects/rule'; +import { expandFirstAlert } from '../../../../tasks/alerts'; +import { createRule } from '../../../../tasks/api_calls/rules'; +import { deleteAlertsAndRules } from '../../../../tasks/api_calls/common'; +import { ALERTS_URL } from '../../../../urls/navigation'; +import { waitForAlertsToPopulate } from '../../../../tasks/create_new_rule'; +import { + asigneesMenuItemsAreNotAvailable, + cannotAddAssigneesViaDetailsFlyout, + loadPageAs, +} from '../../../../tasks/alert_assignments'; + +describe('Alert user assignment - Basic License', { tags: ['@ess'] }, () => { + before(() => { + cy.task('esArchiverLoad', { archiveName: 'auditbeat_multiple' }); + login(); + cy.request({ + method: 'POST', + url: '/api/license/start_basic?acknowledge=true', + headers: { + 'kbn-xsrf': 'cypress-creds', + 'x-elastic-internal-origin': 'security-solution', + }, + }).then(({ body }) => { + cy.log(`body: ${JSON.stringify(body)}`); + expect(body).contains({ + acknowledged: true, + basic_was_started: true, + }); + }); + }); + + after(() => { + cy.task('esArchiverUnload', 'auditbeat_multiple'); + }); + + beforeEach(() => { + loadPageAs(ALERTS_URL); + deleteAlertsAndRules(); + createRule(getNewRule({ rule_id: 'new custom rule' })); + waitForAlertsToPopulate(); + }); + + it('user with Basic license should not be able to update assignees', () => { + // Check alerts table + asigneesMenuItemsAreNotAvailable(); + + // Check alert's details flyout + expandFirstAlert(); + cannotAddAssigneesViaDetailsFlyout(); + }); +}); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/assignments/assignments_serverless_complete.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/assignments/assignments_serverless_complete.cy.ts new file mode 100644 index 0000000000000..ff9f3801644a2 --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/assignments/assignments_serverless_complete.cy.ts @@ -0,0 +1,88 @@ +/* + * 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 { ROLES } from '@kbn/security-solution-plugin/common/test'; +import { getNewRule } from '../../../../objects/rule'; +import { refreshAlertPageFilter, selectFirstPageAlerts } from '../../../../tasks/alerts'; +import { createRule } from '../../../../tasks/api_calls/rules'; +import { deleteAlertsAndRules } from '../../../../tasks/api_calls/common'; +import { login } from '../../../../tasks/login'; +import { ALERTS_URL } from '../../../../urls/navigation'; +import { waitForAlertsToPopulate } from '../../../../tasks/create_new_rule'; +import { + alertsTableShowsAssigneesForAlert, + updateAssigneesForAlert, + bulkRemoveAllAssignees, + loadPageAs, +} from '../../../../tasks/alert_assignments'; + +describe( + 'Alert user assignment - Serverless Complete', + { + tags: ['@serverless'], + env: { + ftrConfig: { + productTypes: [ + { product_line: 'security', product_tier: 'complete' }, + { product_line: 'endpoint', product_tier: 'complete' }, + ], + }, + }, + }, + () => { + before(() => { + cy.task('esArchiverLoad', { archiveName: 'auditbeat_multiple' }); + + // Login into accounts so that they got activated and visible in user profiles list + login(ROLES.t1_analyst); + login(ROLES.t2_analyst); + login(ROLES.t3_analyst); + login(ROLES.soc_manager); + login(ROLES.detections_admin); + login(ROLES.platform_engineer); + }); + + after(() => { + cy.task('esArchiverUnload', 'auditbeat_multiple'); + }); + + beforeEach(() => { + loadPageAs(ALERTS_URL); + deleteAlertsAndRules(); + createRule(getNewRule({ rule_id: 'new custom rule' })); + waitForAlertsToPopulate(); + }); + + context('Authorization / RBAC', () => { + it('users with editing privileges should be able to update assignees', () => { + const editors = [ + ROLES.t1_analyst, + ROLES.t2_analyst, + ROLES.t3_analyst, + ROLES.rule_author, + ROLES.soc_manager, + ROLES.detections_admin, + ROLES.platform_engineer, + ]; + editors.forEach((role) => { + loadPageAs(ALERTS_URL, role); + waitForAlertsToPopulate(); + + // Unassign alert + selectFirstPageAlerts(); + bulkRemoveAllAssignees(); + refreshAlertPageFilter(); + + updateAssigneesForAlert([role]); + + // Assignees should appear in the alerts table + alertsTableShowsAssigneesForAlert([role]); + }); + }); + }); + } +); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/assignments/assignments_serverless_essentials.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/assignments/assignments_serverless_essentials.cy.ts new file mode 100644 index 0000000000000..53436e0102f0a --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/assignments/assignments_serverless_essentials.cy.ts @@ -0,0 +1,88 @@ +/* + * 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 { ROLES } from '@kbn/security-solution-plugin/common/test'; +import { getNewRule } from '../../../../objects/rule'; +import { refreshAlertPageFilter, selectFirstPageAlerts } from '../../../../tasks/alerts'; +import { createRule } from '../../../../tasks/api_calls/rules'; +import { deleteAlertsAndRules } from '../../../../tasks/api_calls/common'; +import { login } from '../../../../tasks/login'; +import { ALERTS_URL } from '../../../../urls/navigation'; +import { waitForAlertsToPopulate } from '../../../../tasks/create_new_rule'; +import { + alertsTableShowsAssigneesForAlert, + updateAssigneesForAlert, + bulkRemoveAllAssignees, + loadPageAs, +} from '../../../../tasks/alert_assignments'; + +describe( + 'Alert user assignment - Serverless Essentials', + { + tags: ['@serverless'], + env: { + ftrConfig: { + productTypes: [ + { product_line: 'security', product_tier: 'essentials' }, + { product_line: 'endpoint', product_tier: 'essentials' }, + ], + }, + }, + }, + () => { + before(() => { + cy.task('esArchiverLoad', { archiveName: 'auditbeat_multiple' }); + + // Login into accounts so that they got activated and visible in user profiles list + login(ROLES.t1_analyst); + login(ROLES.t2_analyst); + login(ROLES.t3_analyst); + login(ROLES.soc_manager); + login(ROLES.detections_admin); + login(ROLES.platform_engineer); + }); + + after(() => { + cy.task('esArchiverUnload', 'auditbeat_multiple'); + }); + + beforeEach(() => { + loadPageAs(ALERTS_URL); + deleteAlertsAndRules(); + createRule(getNewRule({ rule_id: 'new custom rule' })); + waitForAlertsToPopulate(); + }); + + context('Authorization / RBAC', () => { + it('users with editing privileges should be able to update assignees', () => { + const editors = [ + ROLES.t1_analyst, + ROLES.t2_analyst, + ROLES.t3_analyst, + ROLES.rule_author, + ROLES.soc_manager, + ROLES.detections_admin, + ROLES.platform_engineer, + ]; + editors.forEach((role) => { + loadPageAs(ALERTS_URL, role); + waitForAlertsToPopulate(); + + // Unassign alert + selectFirstPageAlerts(); + bulkRemoveAllAssignees(); + refreshAlertPageFilter(); + + updateAssigneesForAlert([role]); + + // Assignees should appear in the alerts table + alertsTableShowsAssigneesForAlert([role]); + }); + }); + }); + } +); diff --git a/x-pack/test/security_solution_cypress/cypress/screens/alerts.ts b/x-pack/test/security_solution_cypress/cypress/screens/alerts.ts index 0eaec7ce0b471..cfcd52372daeb 100644 --- a/x-pack/test/security_solution_cypress/cypress/screens/alerts.ts +++ b/x-pack/test/security_solution_cypress/cypress/screens/alerts.ts @@ -33,6 +33,8 @@ export const ALERT_SEVERITY = '[data-test-subj="formatted-field-kibana.alert.sev export const ALERT_DATA_GRID = '[data-test-subj="euiDataGridBody"]'; +export const ALERT_DATA_GRID_ROW = `${ALERT_DATA_GRID} .euiDataGridRow`; + export const ALERTS_COUNT = '[data-test-subj="toolbar-alerts-count"]'; export const CLOSE_ALERT_BTN = '[data-test-subj="close-alert-status"]'; @@ -180,3 +182,36 @@ export const ALERT_RENDERER_HOST_NAME = '[data-test-subj="alertFieldBadge"] [data-test-subj="render-content-host.name"]'; export const HOVER_ACTIONS_CONTAINER = getDataTestSubjectSelector('hover-actions-container'); + +export const ALERT_USERS_PROFILES_SELECTABLE_MENU_ITEM = '.euiSelectableListItem'; +export const ALERT_USERS_PROFILES_CLEAR_SEARCH_BUTTON = '[data-test-subj="clearSearchButton"]'; + +export const ALERT_ASSIGN_CONTEXT_MENU_ITEM = + '[data-test-subj="alert-assignees-context-menu-item"]'; + +export const ALERT_UNASSIGN_CONTEXT_MENU_ITEM = + '[data-test-subj="remove-alert-assignees-menu-item"]'; + +export const ALERT_ASSIGNEES_SELECT_PANEL = + '[data-test-subj="securitySolutionAssigneesApplyPanel"]'; + +export const ALERT_ASSIGNEES_UPDATE_BUTTON = + '[data-test-subj="securitySolutionAssigneesApplyButton"]'; + +export const ALERT_USER_AVATAR = (assignee: string) => + `[data-test-subj="securitySolutionUsersAvatar-${assignee}"][title='${assignee}']`; + +export const ALERT_AVATARS_PANEL = '[data-test-subj="securitySolutionUsersAvatarsPanel"]'; + +export const ALERT_ASIGNEES_COLUMN = + '[data-test-subj="dataGridRowCell"][data-gridcell-column-id="kibana.alert.workflow_assignee_ids"]'; + +export const ALERT_ASSIGNEES_COUNT_BADGE = + '[data-test-subj="securitySolutionUsersAvatarsCountBadge"]'; + +export const FILTER_BY_ASSIGNEES_BUTTON = '[data-test-subj="filter-popover-button-assignees"]'; + +export const ALERT_DETAILS_ASSIGN_BUTTON = + '[data-test-subj="securitySolutionFlyoutHeaderAssigneesAddButton"]'; + +export const ALERT_DETAILS_TAKE_ACTION_BUTTON = '[data-test-subj="take-action-dropdown-btn"]'; diff --git a/x-pack/test/security_solution_cypress/cypress/screens/expandable_flyout/alert_details_right_panel.ts b/x-pack/test/security_solution_cypress/cypress/screens/expandable_flyout/alert_details_right_panel.ts index abf9585e368ec..afe87189acd37 100644 --- a/x-pack/test/security_solution_cypress/cypress/screens/expandable_flyout/alert_details_right_panel.ts +++ b/x-pack/test/security_solution_cypress/cypress/screens/expandable_flyout/alert_details_right_panel.ts @@ -17,6 +17,7 @@ import { SEVERITY_VALUE_TEST_ID, STATUS_BUTTON_TEST_ID, FLYOUT_HEADER_TITLE_TEST_ID, + ASSIGNEES_HEADER_TEST_ID, } from '@kbn/security-solution-plugin/public/flyout/document_details/right/components/test_ids'; import { COLLAPSE_DETAILS_BUTTON_TEST_ID, @@ -59,6 +60,8 @@ export const DOCUMENT_DETAILS_FLYOUT_HEADER_RISK_SCORE_VALUE = getDataTestSubjectSelector(RISK_SCORE_VALUE_TEST_ID); export const DOCUMENT_DETAILS_FLYOUT_HEADER_SEVERITY_VALUE = getDataTestSubjectSelector(SEVERITY_VALUE_TEST_ID); +export const DOCUMENT_DETAILS_FLYOUT_HEADER_ASSIGNEES = + getDataTestSubjectSelector(ASSIGNEES_HEADER_TEST_ID); /* Footer */ diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/alert_assignments.ts b/x-pack/test/security_solution_cypress/cypress/tasks/alert_assignments.ts new file mode 100644 index 0000000000000..c0adfbf1d78fa --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/tasks/alert_assignments.ts @@ -0,0 +1,236 @@ +/* + * 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 { SecurityRoleName } from '@kbn/security-solution-plugin/common/test'; +import { + ALERTS_TABLE_ROW_LOADER, + ALERT_AVATARS_PANEL, + ALERT_ASSIGNEES_SELECT_PANEL, + ALERT_ASSIGN_CONTEXT_MENU_ITEM, + ALERT_ASSIGNEES_UPDATE_BUTTON, + ALERT_USER_AVATAR, + ALERT_DATA_GRID_ROW, + ALERT_DETAILS_ASSIGN_BUTTON, + ALERT_DETAILS_TAKE_ACTION_BUTTON, + ALERT_UNASSIGN_CONTEXT_MENU_ITEM, + ALERT_USERS_PROFILES_CLEAR_SEARCH_BUTTON, + ALERT_USERS_PROFILES_SELECTABLE_MENU_ITEM, + ALERT_ASIGNEES_COLUMN, + ALERT_ASSIGNEES_COUNT_BADGE, + FILTER_BY_ASSIGNEES_BUTTON, + TAKE_ACTION_POPOVER_BTN, + TIMELINE_CONTEXT_MENU_BTN, +} from '../screens/alerts'; +import { PAGE_TITLE } from '../screens/common/page'; +import { DOCUMENT_DETAILS_FLYOUT_HEADER_ASSIGNEES } from '../screens/expandable_flyout/alert_details_right_panel'; +import { selectFirstPageAlerts } from './alerts'; +import { login } from './login'; +import { visitWithTimeRange } from './navigation'; + +export const NO_ASSIGNEES = 'No assignees'; + +export const waitForAssigneesToPopulatePopover = () => { + cy.waitUntil( + () => { + cy.log('Waiting for assignees to appear in popover'); + return cy.root().then(($el) => { + const $updateButton = $el.find(ALERT_ASSIGNEES_UPDATE_BUTTON); + return !$updateButton.prop('disabled'); + }); + }, + { interval: 500, timeout: 12000 } + ); +}; + +export const waitForPageTitleToBeShown = () => { + cy.get(PAGE_TITLE).should('be.visible'); +}; + +export const loadPageAs = (url: string, role?: SecurityRoleName) => { + login(role); + visitWithTimeRange(url); + waitForPageTitleToBeShown(); +}; + +export const openAlertAssigningActionMenu = (alertIndex = 0) => { + cy.get(TIMELINE_CONTEXT_MENU_BTN).eq(alertIndex).click(); + cy.get(ALERT_ASSIGN_CONTEXT_MENU_ITEM).click(); +}; + +export const openAlertAssigningBulkActionMenu = () => { + cy.get(TAKE_ACTION_POPOVER_BTN).click(); + cy.get(ALERT_ASSIGN_CONTEXT_MENU_ITEM).click(); +}; + +export const updateAlertAssignees = () => { + cy.get(ALERT_ASSIGNEES_UPDATE_BUTTON).click(); +}; + +export const checkEmptyAssigneesStateInAlertsTable = () => { + cy.get(ALERT_DATA_GRID_ROW) + .its('length') + .then((count) => { + cy.get(ALERT_ASIGNEES_COLUMN).should('have.length', count); + }); + cy.get(ALERT_ASIGNEES_COLUMN).each(($column) => { + cy.wrap($column).within(() => { + cy.get(ALERT_AVATARS_PANEL).children().should('have.length', 0); + }); + }); +}; + +export const checkEmptyAssigneesStateInAlertDetailsFlyout = () => { + cy.get(DOCUMENT_DETAILS_FLYOUT_HEADER_ASSIGNEES).within(() => { + cy.get(ALERT_AVATARS_PANEL).children().should('have.length', 0); + }); +}; + +export const alertsTableMoreActionsAreNotAvailable = () => { + cy.get(TIMELINE_CONTEXT_MENU_BTN).should('not.exist'); +}; + +export const asigneesMenuItemsAreNotAvailable = (alertIndex = 0) => { + cy.get(TIMELINE_CONTEXT_MENU_BTN).eq(alertIndex).click(); + cy.get(ALERT_ASSIGN_CONTEXT_MENU_ITEM).should('not.exist'); + cy.get(ALERT_UNASSIGN_CONTEXT_MENU_ITEM).should('not.exist'); +}; + +export const asigneesBulkMenuItemsAreNotAvailable = () => { + selectFirstPageAlerts(); + cy.get(TAKE_ACTION_POPOVER_BTN).click(); + cy.get(ALERT_ASSIGN_CONTEXT_MENU_ITEM).should('not.exist'); + cy.get(ALERT_UNASSIGN_CONTEXT_MENU_ITEM).should('not.exist'); +}; + +export const cannotAddAssigneesViaDetailsFlyout = () => { + cy.get(ALERT_DETAILS_ASSIGN_BUTTON).should('be.disabled'); +}; + +export const alertsTableShowsAssigneesForAlert = (users: SecurityRoleName[], alertIndex = 0) => { + cy.get(ALERT_ASIGNEES_COLUMN) + .eq(alertIndex) + .within(() => { + users.forEach((user) => cy.get(`.euiAvatar${ALERT_USER_AVATAR(user)}`).should('exist')); + }); +}; + +export const alertsTableShowsAssigneesForAllAlerts = (users: SecurityRoleName[]) => { + cy.get(ALERT_ASIGNEES_COLUMN).each(($column) => { + cy.wrap($column).within(() => { + users.forEach((user) => cy.get(`.euiAvatar${ALERT_USER_AVATAR(user)}`).should('exist')); + }); + }); +}; + +export const alertsTableShowsAssigneesBadgeForAlert = ( + users: SecurityRoleName[], + alertIndex = 0 +) => { + cy.get(ALERT_ASIGNEES_COLUMN) + .eq(alertIndex) + .within(() => { + cy.get(ALERT_ASSIGNEES_COUNT_BADGE).contains(users.length); + users.forEach((user) => cy.get(`.euiAvatar${ALERT_USER_AVATAR(user)}`).should('not.exist')); + }); +}; + +export const alertDetailsFlyoutShowsAssignees = (users: SecurityRoleName[]) => { + cy.get(DOCUMENT_DETAILS_FLYOUT_HEADER_ASSIGNEES).within(() => { + users.forEach((user) => cy.get(`.euiAvatar${ALERT_USER_AVATAR(user)}`).should('exist')); + }); +}; + +export const alertDetailsFlyoutShowsAssigneesBadge = (users: SecurityRoleName[]) => { + cy.get(DOCUMENT_DETAILS_FLYOUT_HEADER_ASSIGNEES).within(() => { + cy.get(ALERT_ASSIGNEES_COUNT_BADGE).contains(users.length); + users.forEach((user) => cy.get(`.euiAvatar${ALERT_USER_AVATAR(user)}`).should('not.exist')); + }); +}; + +export const selectAlertAssignee = (assignee: string) => { + cy.get(ALERT_ASSIGNEES_SELECT_PANEL).within(() => { + if (assignee === NO_ASSIGNEES) { + cy.get(ALERT_USERS_PROFILES_SELECTABLE_MENU_ITEM).contains(assignee).click(); + return; + } + cy.get('input').type(assignee); + cy.get(ALERT_USERS_PROFILES_SELECTABLE_MENU_ITEM).contains(assignee).click(); + cy.get(ALERT_USERS_PROFILES_CLEAR_SEARCH_BUTTON).click(); + }); +}; + +/** + * This will update assignees for selected alert + * @param users The list of assugnees to update. If assignee is not assigned yet it will be assigned, otherwise it will be unassigned + * @param alertIndex The index of the alert in the alerts table + */ +export const updateAssigneesForAlert = (users: SecurityRoleName[], alertIndex = 0) => { + openAlertAssigningActionMenu(alertIndex); + waitForAssigneesToPopulatePopover(); + users.forEach((user) => selectAlertAssignee(user)); + updateAlertAssignees(); + cy.get(ALERTS_TABLE_ROW_LOADER).should('not.exist'); +}; + +export const updateAssigneesViaAddButtonInFlyout = (users: SecurityRoleName[]) => { + cy.get(ALERT_DETAILS_ASSIGN_BUTTON).click(); + waitForAssigneesToPopulatePopover(); + users.forEach((user) => selectAlertAssignee(user)); + updateAlertAssignees(); + cy.get(ALERTS_TABLE_ROW_LOADER).should('not.exist'); +}; + +export const updateAssigneesViaTakeActionButtonInFlyout = (users: SecurityRoleName[]) => { + cy.get(ALERT_DETAILS_TAKE_ACTION_BUTTON).click(); + cy.get(ALERT_ASSIGN_CONTEXT_MENU_ITEM).click(); + waitForAssigneesToPopulatePopover(); + users.forEach((user) => selectAlertAssignee(user)); + updateAlertAssignees(); + cy.get(ALERTS_TABLE_ROW_LOADER).should('not.exist'); +}; + +export const bulkUpdateAssignees = (users: SecurityRoleName[]) => { + openAlertAssigningBulkActionMenu(); + waitForAssigneesToPopulatePopover(); + users.forEach((user) => selectAlertAssignee(user)); + updateAlertAssignees(); + cy.get(ALERTS_TABLE_ROW_LOADER).should('not.exist'); +}; + +export const removeAllAssigneesForAlert = (alertIndex = 0) => { + cy.get(TIMELINE_CONTEXT_MENU_BTN).eq(alertIndex).click(); + cy.get(ALERT_UNASSIGN_CONTEXT_MENU_ITEM).click(); + cy.get(ALERTS_TABLE_ROW_LOADER).should('not.exist'); +}; + +export const removeAllAssigneesViaTakeActionButtonInFlyout = () => { + cy.get(ALERT_DETAILS_TAKE_ACTION_BUTTON).click(); + cy.get(ALERT_UNASSIGN_CONTEXT_MENU_ITEM).click(); + cy.get(ALERTS_TABLE_ROW_LOADER).should('not.exist'); +}; + +export const bulkRemoveAllAssignees = () => { + cy.get(TAKE_ACTION_POPOVER_BTN).click(); + cy.get(ALERT_UNASSIGN_CONTEXT_MENU_ITEM).click(); + cy.get(ALERTS_TABLE_ROW_LOADER).should('not.exist'); +}; + +export const filterByAssignees = (users: Array) => { + cy.get(FILTER_BY_ASSIGNEES_BUTTON).scrollIntoView(); + cy.get(FILTER_BY_ASSIGNEES_BUTTON).click(); + users.forEach((user) => selectAlertAssignee(user)); + cy.get(FILTER_BY_ASSIGNEES_BUTTON).click(); +}; + +export const clearAssigneesFilter = () => { + cy.get(FILTER_BY_ASSIGNEES_BUTTON).scrollIntoView(); + cy.get(FILTER_BY_ASSIGNEES_BUTTON).click(); + cy.get(ALERT_ASSIGNEES_SELECT_PANEL).within(() => { + cy.contains('Clear filters').click(); + }); + cy.get(FILTER_BY_ASSIGNEES_BUTTON).click(); +};