From 23f64eda6d1a1ab71ea19fa01c8f36795edb3814 Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Fri, 22 Sep 2023 19:18:10 +0200 Subject: [PATCH 01/53] [Security Solution][Detections] Extend alerts schema to accommodate the list of assigned users (#7647) (#166845) ## Summary Closes https://github.com/elastic/security-team/issues/7647 This PR extends alert's schema. We add a new field `kibana.alert.workflow_assignee_ids` where assignees will live. --- .../src/field_maps/alert_field_map.ts | 6 ++ .../src/schemas/generated/alert_schema.ts | 1 + .../src/schemas/generated/security_schema.ts | 1 + .../src/search/security/fields.ts | 2 + .../src/default_alerts_as_data.ts | 5 ++ .../src/technical_field_names.ts | 2 + .../src/signal/index.ts | 1 + .../field_maps/mapping_from_field_map.test.ts | 3 + .../technical_rule_field_map.test.ts | 5 ++ .../model/alerts/8.11.0/index.ts | 56 +++++++++++++++++++ .../detection_engine/model/alerts/index.ts | 34 +++++------ .../alerts_table/default_config.tsx | 1 + .../rule_types/__mocks__/es_results.ts | 2 + .../factories/utils/build_alert.test.ts | 3 + .../rule_types/factories/utils/build_alert.ts | 2 + .../utils/enrichments/__mocks__/alerts.ts | 2 + .../group6/alerts/alerts_compatibility.ts | 2 + .../rule_execution_logic/eql.ts | 2 + .../rule_execution_logic/machine_learning.ts | 2 + .../rule_execution_logic/new_terms.ts | 1 + .../rule_execution_logic/threat_match.ts | 2 + 21 files changed, 119 insertions(+), 16 deletions(-) create mode 100644 x-pack/plugins/security_solution/common/api/detection_engine/model/alerts/8.11.0/index.ts 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 f22e902bbbeaa..2747f0d84dba6 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, @@ -182,6 +183,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 4978d8b1fa1e4..4b14fda5df2cd 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 @@ -116,6 +116,7 @@ const AlertOptional = rt.partial({ start: schemaDate, time_range: schemaDateRange, url: schemaString, + workflow_assignee_ids: schemaStringArray, workflow_status: schemaString, workflow_tags: schemaStringArray, }), 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 03124f6bef160..e1ea5f0863e4c 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 @@ -234,6 +234,7 @@ const SecurityAlertOptional = rt.partial({ }), time_range: schemaDateRange, url: schemaString, + workflow_assignee_ids: schemaStringArray, workflow_reason: schemaString, workflow_status: schemaString, 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 3b2ea148591dc..87175f3d824ed 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 5f0570fa9542e..5ab6ad139a74a 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, @@ -168,6 +169,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/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 e58b795863e48..942737d819c40 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 @@ -306,6 +306,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 4d25b41b6db0e..efbd3483bb13e 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 @@ -286,6 +286,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/model/alerts/8.11.0/index.ts b/x-pack/plugins/security_solution/common/api/detection_engine/model/alerts/8.11.0/index.ts new file mode 100644 index 0000000000000..d9b682027bfc7 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/model/alerts/8.11.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.11.0. +Any changes to these types should be bug fixes so the types more accurately represent the alerts from 8.11.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 Ancestor8110 }; + +export interface BaseFields8110 extends BaseFields890 { + [ALERT_WORKFLOW_ASSIGNEE_IDS]: string[] | undefined; +} + +export interface WrappedFields8110 { + _id: string; + _index: string; + _source: T; +} + +export type GenericAlert8110 = AlertWithCommonFields800; + +export type EqlShellFields8110 = EqlShellFields890 & BaseFields8110; + +export type EqlBuildingBlockFields8110 = EqlBuildingBlockFields890 & BaseFields8110; + +export type NewTermsFields8110 = NewTermsFields890 & BaseFields8110; + +export type NewTermsAlert8110 = NewTermsFields890 & BaseFields8110; + +export type EqlBuildingBlockAlert8110 = AlertWithCommonFields800; + +export type EqlShellAlert8110 = AlertWithCommonFields800; + +export type DetectionAlert8110 = + | GenericAlert8110 + | EqlShellAlert8110 + | EqlBuildingBlockAlert8110 + | NewTermsAlert8110; 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..a56bd2068549a 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'; + Ancestor8110, + BaseFields8110, + DetectionAlert8110, + EqlBuildingBlockFields8110, + EqlShellFields8110, + NewTermsFields8110, + WrappedFields8110, +} from './8.11.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 + | DetectionAlert8110; export type { - Ancestor890 as AncestorLatest, - BaseFields890 as BaseFieldsLatest, - DetectionAlert890 as DetectionAlertLatest, - WrappedFields890 as WrappedFieldsLatest, - EqlBuildingBlockFields890 as EqlBuildingBlockFieldsLatest, - EqlShellFields890 as EqlShellFieldsLatest, - NewTermsFields890 as NewTermsFieldsLatest, + Ancestor8110 as AncestorLatest, + BaseFields8110 as BaseFieldsLatest, + DetectionAlert8110 as DetectionAlertLatest, + WrappedFields8110 as WrappedFieldsLatest, + EqlBuildingBlockFields8110 as EqlBuildingBlockFieldsLatest, + EqlShellFields8110 as EqlShellFieldsLatest, + NewTermsFields8110 as NewTermsFieldsLatest, }; 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..10204204e9fc3 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 @@ -177,6 +177,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/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 2309833a947f0..683bea5754495 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, @@ -248,6 +249,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/test/detection_engine_api_integration/security_and_spaces/group6/alerts/alerts_compatibility.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group6/alerts/alerts_compatibility.ts index 9e7b6265a2b9f..bd623d4cfe051 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group6/alerts/alerts_compatibility.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group6/alerts/alerts_compatibility.ts @@ -321,6 +321,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 Signal Testing Query.', @@ -483,6 +484,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 Signal Testing Query.', diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/eql.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/eql.ts index b0469c90d8e4d..0bf5cc888f587 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/eql.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_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'; @@ -150,6 +151,7 @@ export default ({ getService }: FtrProviderContext) => { [ALERT_ORIGINAL_TIME]: fullSignal[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/detection_engine_api_integration/security_and_spaces/rule_execution_logic/machine_learning.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/machine_learning.ts index b9fd707e3775b..8507eb9c637a0 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/machine_learning.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_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'; @@ -120,6 +121,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/detection_engine_api_integration/security_and_spaces/rule_execution_logic/new_terms.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/new_terms.ts index 0ac86c991015d..8ef7c45e73678 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/new_terms.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/new_terms.ts @@ -167,6 +167,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/detection_engine_api_integration/security_and_spaces/rule_execution_logic/threat_match.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/threat_match.ts index 0638765283a6e..0a8e663ac6caf 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/threat_match.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_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'; @@ -289,6 +290,7 @@ export default ({ getService }: FtrProviderContext) => { [ALERT_UUID]: fullSignal[ALERT_UUID], [ALERT_WORKFLOW_STATUS]: 'open', [ALERT_WORKFLOW_TAGS]: [], + [ALERT_WORKFLOW_ASSIGNEE_IDS]: [], [SPACE_IDS]: ['default'], [VERSION]: fullSignal[VERSION], threat: { From dc9fcfff461e44743e8ec87f1ba93822466f2eb5 Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Tue, 10 Oct 2023 14:33:59 +0200 Subject: [PATCH 02/53] [Security Solution][Detections] Add assignees UI into alerts table (#7661) (#167079) ## Summary Closes https://github.com/elastic/security-team/issues/7661 This PR adds Alert user assignment UI within alerts table. https://github.com/elastic/kibana/assets/2700761/df81928a-b0c7-46ba-98b3-3803774ed239 --- .../detection_engine/alert_assignees/index.ts | 8 + .../detection_engine/alert_assignees/mocks.ts | 8 + .../set_alert_assignees_route.mock.ts | 17 ++ .../set_alert_assignees_route.ts | 20 ++ .../api/detection_engine/model/schemas.ts | 13 ++ .../api/detection_engine/users/index.ts | 8 + .../suggest_user_profiles_route.ts | 19 ++ .../security_solution/common/constants.ts | 4 + .../alert_bulk_assignees.test.tsx | 189 ++++++++++++++++++ .../bulk_actions/alert_bulk_assignees.tsx | 142 +++++++++++++ .../toolbar/bulk_actions/translations.ts | 62 ++++++ .../use_bulk_alert_assignees_items.test.tsx | 108 ++++++++++ .../use_bulk_alert_assignees_items.tsx | 100 +++++++++ .../bulk_actions/use_set_alert_assignees.tsx | 86 ++++++++ .../common/containers/alert_assignees/api.ts | 31 +++ .../alert_context_menu.test.tsx | 13 ++ .../timeline_actions/alert_context_menu.tsx | 12 +- .../use_alert_assignees_actions.test.tsx | 177 ++++++++++++++++ .../use_alert_assignees_actions.tsx | 81 ++++++++ .../components/alerts_table/translations.ts | 7 + .../security_solution_detections/columns.ts | 6 + .../render_cell_value.tsx | 61 +++++- .../detection_engine/alerts/__mocks__/api.ts | 15 +- .../detection_engine/alerts/api.test.ts | 26 +++ .../containers/detection_engine/alerts/api.ts | 19 ++ .../detection_engine/alerts/mock.ts | 6 + .../detection_engine/alerts/translations.ts | 5 + .../detection_engine/alerts/types.ts | 4 + .../alerts/use_get_user_profiles.test.tsx | 49 +++++ .../alerts/use_get_user_profiles.tsx | 55 +++++ .../alerts/use_suggest_users.test.tsx | 36 ++++ .../alerts/use_suggest_users.tsx | 48 +++++ .../use_bulk_actions.tsx | 9 +- .../timeline/body/renderers/constants.tsx | 1 + .../routes/__mocks__/request_responses.ts | 5 + .../routes/signals/helpers.ts | 15 +- .../signals/set_alert_assignees_route.test.ts | 115 +++++++++++ .../signals/set_alert_assignees_route.ts | 123 ++++++++++++ .../routes/signals/translations.ts | 7 + .../users/suggest_user_profiles_route.test.ts | 65 ++++++ .../users/suggest_user_profiles_route.ts | 73 +++++++ .../security_solution/server/routes/index.ts | 4 + .../bulk_actions/bulk_actions.test.tsx | 16 ++ .../bulk_actions/components/toolbar.tsx | 2 + .../rule_execution_logic/esql.ts | 1 + .../detection_alerts/alert_assignees.cy.ts | 75 +++++++ .../cypress/screens/alerts.ts | 13 ++ .../cypress/tasks/alert_assignees.ts | 55 +++++ .../cypress/tasks/alerts.ts | 31 +++ 49 files changed, 2037 insertions(+), 8 deletions(-) create mode 100644 x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/index.ts create mode 100644 x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/mocks.ts create mode 100644 x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/set_alert_assignees/set_alert_assignees_route.mock.ts create mode 100644 x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/set_alert_assignees/set_alert_assignees_route.ts create mode 100644 x-pack/plugins/security_solution/common/api/detection_engine/users/index.ts create mode 100644 x-pack/plugins/security_solution/common/api/detection_engine/users/suggest_user_profiles/suggest_user_profiles_route.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/alert_bulk_assignees.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/alert_bulk_assignees.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_set_alert_assignees.tsx create mode 100644 x-pack/plugins/security_solution/public/common/containers/alert_assignees/api.ts create mode 100644 x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_assignees_actions.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_assignees_actions.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_get_user_profiles.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_get_user_profiles.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_suggest_users.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_suggest_users.tsx create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_assignees_route.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_assignees_route.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/routes/users/suggest_user_profiles_route.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/routes/users/suggest_user_profiles_route.ts create mode 100644 x-pack/test/security_solution_cypress/cypress/e2e/detection_alerts/alert_assignees.cy.ts create mode 100644 x-pack/test/security_solution_cypress/cypress/tasks/alert_assignees.ts 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..e0fa0d8eb6408 --- /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/set_alert_assignees_route'; 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..15b16eecb2868 --- /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/set_alert_assignees_route.mock'; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/set_alert_assignees/set_alert_assignees_route.mock.ts b/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/set_alert_assignees/set_alert_assignees_route.mock.ts new file mode 100644 index 0000000000000..9678131010702 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/set_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'; + +export const getSetAlertAssigneesRequestMock = ( + assigneesToAdd: string[] = [], + assigneesToRemove: string[] = [], + ids: string[] = [] +): SetAlertAssigneesRequestBody => ({ + assignees: { assignees_to_add: assigneesToAdd, assignees_to_remove: assigneesToRemove }, + ids, +}); diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/set_alert_assignees/set_alert_assignees_route.ts b/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/set_alert_assignees/set_alert_assignees_route.ts new file mode 100644 index 0000000000000..6cc6514e6d9ee --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/set_alert_assignees/set_alert_assignees_route.ts @@ -0,0 +1,20 @@ +/* + * 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 * as t from 'io-ts'; + +import { alert_assignee_ids, alert_assignees } from '../../model'; + +export const setAlertAssigneesRequestBody = t.exact( + t.type({ + assignees: alert_assignees, + ids: alert_assignee_ids, + }) +); + +export type SetAlertAssigneesRequestBody = t.TypeOf; +export type SetAlertAssigneesRequestBodyDecoded = SetAlertAssigneesRequestBody; 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 7e8cb0ebbe58b..148ed979d21b1 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 @@ -48,6 +48,9 @@ export const signal_status_query = t.object; export const alert_tag_ids = t.array(t.string); export type AlertTagIds = t.TypeOf; +export const alert_assignee_ids = t.array(t.string); +export type AlertAssigneeIds = t.TypeOf; + export const fields = t.array(t.string); export type Fields = t.TypeOf; export const fieldsOrUndefined = t.union([fields, t.undefined]); @@ -135,3 +138,13 @@ export const alert_tags = t.type({ }); export type AlertTags = t.TypeOf; + +export const alert_assignees = t.type({ + assignees_to_add: t.array(t.string), + assignees_to_remove: t.array(t.string), +}); + +export type AlertAssignees = 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..f931f063971a3 --- /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/suggest_user_profiles_route'; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/users/suggest_user_profiles/suggest_user_profiles_route.ts b/x-pack/plugins/security_solution/common/api/detection_engine/users/suggest_user_profiles/suggest_user_profiles_route.ts new file mode 100644 index 0000000000000..12f87860fb002 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/users/suggest_user_profiles/suggest_user_profiles_route.ts @@ -0,0 +1,19 @@ +/* + * 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 * as t from 'io-ts'; + +import { user_search_term } from '../../model'; + +export const suggestUserProfilesRequestQuery = t.exact( + t.partial({ + searchTerm: user_search_term, + }) +); + +export type SuggestUserProfilesRequestQuery = t.TypeOf; +export type SuggestUserProfilesRequestQueryDecoded = SuggestUserProfilesRequestQuery; diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index b282b127f36f5..11a578eb09724 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -316,6 +316,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}/suggest_users` 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/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..a3a76c4bea327 --- /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 type { UserProfileWithAvatar } from '@kbn/user-profile-components'; +import { act, fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import { TestProviders } from '../../../mock'; +import { useSuggestUsers } from '../../../../detections/containers/detection_engine/alerts/use_suggest_users'; + +import { BulkAlertAssigneesPanel } from './alert_bulk_assignees'; +import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils'; + +jest.mock('../../../../detections/containers/detection_engine/alerts/use_suggest_users'); + +const mockUserProfiles: UserProfileWithAvatar[] = [ + { uid: 'default-test-assignee-id-1', enabled: true, user: { username: 'user1' }, data: {} }, + { uid: 'default-test-assignee-id-2', enabled: true, user: { username: 'user2' }, data: {} }, +]; + +const mockAssigneeItems = [ + { + _id: 'test-id', + data: [{ field: ALERT_WORKFLOW_ASSIGNEE_IDS, value: ['assignee-id-1', 'assignee-id-2'] }], + ecs: { _id: 'test-id' }, + }, +]; + +(useSuggestUsers as jest.Mock).mockReturnValue({ loading: false, userProfiles: mockUserProfiles }); + +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(mockAssigneeItems); + + expect(wrapper.getByTestId('alert-assignees-update-button')).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 mockAssignees = [ + { + _id: 'test-id', + data: [{ field: ALERT_WORKFLOW_ASSIGNEE_IDS, value: ['default-test-assignee-id-1'] }], + ecs: { _id: 'test-id' }, + }, + { + _id: 'test-id', + data: [ + { + field: ALERT_WORKFLOW_ASSIGNEE_IDS, + value: ['default-test-assignee-id-1', 'default-test-assignee-id-2'], + }, + ], + ecs: { _id: 'test-id' }, + }, + ]; + const wrapper = renderAssigneesMenu( + mockAssignees, + mockedClosePopover, + mockedOnSubmit, + mockedSetIsLoading + ); + + act(() => { + fireEvent.click(wrapper.getByTestId('alert-assignees-update-button')); + }); + expect(mockedClosePopover).toHaveBeenCalled(); + expect(mockedOnSubmit).not.toHaveBeenCalled(); + expect(mockedSetIsLoading).not.toHaveBeenCalled(); + }); + + test('it updates state correctly', () => { + const mockAssignees = [ + { + _id: 'test-id', + data: [{ field: ALERT_WORKFLOW_ASSIGNEE_IDS, value: ['default-test-assignee-id-1'] }], + ecs: { _id: 'test-id' }, + }, + { + _id: 'test-id', + data: [ + { + field: ALERT_WORKFLOW_ASSIGNEE_IDS, + value: ['default-test-assignee-id-1', 'default-test-assignee-id-2'], + }, + ], + ecs: { _id: 'test-id' }, + }, + ]; + const wrapper = renderAssigneesMenu(mockAssignees); + + expect(wrapper.getAllByRole('option')[0]).toHaveAttribute('title', 'user1'); + expect(wrapper.getAllByRole('option')[0]).toBeChecked(); + act(() => { + fireEvent.click(wrapper.getByText('user1')); + }); + expect(wrapper.getAllByRole('option')[0]).toHaveAttribute('title', 'user1'); + expect(wrapper.getAllByRole('option')[0]).not.toBeChecked(); + + expect(wrapper.getAllByRole('option')[1]).toHaveAttribute('title', 'user2'); + expect(wrapper.getAllByRole('option')[1]).not.toBeChecked(); + act(() => { + fireEvent.click(wrapper.getByText('user2')); + }); + expect(wrapper.getAllByRole('option')[1]).toHaveAttribute('title', 'user2'); + expect(wrapper.getAllByRole('option')[1]).toBeChecked(); + }); + + test('it calls expected functions on submit when alerts have changed', () => { + const mockedClosePopover = jest.fn(); + const mockedOnSubmit = jest.fn(); + const mockedSetIsLoading = jest.fn(); + + const mockAssignees = [ + { + _id: 'test-id', + data: [{ field: ALERT_WORKFLOW_ASSIGNEE_IDS, value: ['default-test-assignee-id-1'] }], + ecs: { _id: 'test-id' }, + }, + { + _id: 'test-id', + data: [ + { + field: ALERT_WORKFLOW_ASSIGNEE_IDS, + value: ['default-test-assignee-id-1', 'default-test-assignee-id-2'], + }, + ], + ecs: { _id: 'test-id' }, + }, + ]; + const wrapper = renderAssigneesMenu( + mockAssignees, + mockedClosePopover, + mockedOnSubmit, + mockedSetIsLoading + ); + act(() => { + fireEvent.click(wrapper.getByText('user1')); + }); + act(() => { + fireEvent.click(wrapper.getByText('user2')); + }); + + act(() => { + fireEvent.click(wrapper.getByTestId('alert-assignees-update-button')); + }); + expect(mockedClosePopover).toHaveBeenCalled(); + expect(mockedOnSubmit).toHaveBeenCalled(); + expect(mockedOnSubmit).toHaveBeenCalledWith( + { + assignees_to_add: ['default-test-assignee-id-2'], + assignees_to_remove: ['default-test-assignee-id-1'], + }, + ['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..c720c70a8b4aa --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/alert_bulk_assignees.tsx @@ -0,0 +1,142 @@ +/* + * 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 { intersection } from 'lodash'; +import { EuiButton } from '@elastic/eui'; +import type { TimelineItem } from '@kbn/timelines-plugin/common'; +import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils'; +import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; +import { UserProfilesSelectable } from '@kbn/user-profile-components'; +import { useSuggestUsers } from '../../../../detections/containers/detection_engine/alerts/use_suggest_users'; +import * as i18n from './translations'; +import type { SetAlertAssigneesFunc } from './use_set_alert_assignees'; + +interface BulkAlertAssigneesPanelComponentProps { + alertItems: TimelineItem[]; + refetchQuery?: () => void; + setIsLoading: (isLoading: boolean) => void; + refresh?: () => void; + clearSelection?: () => void; + closePopoverMenu: () => void; + onSubmit: SetAlertAssigneesFunc; +} +const BulkAlertAssigneesPanelComponent: React.FC = ({ + alertItems, + refresh, + refetchQuery, + setIsLoading, + clearSelection, + closePopoverMenu, + onSubmit, +}) => { + const [searchTerm, setSearchTerm] = useState(''); + const { loading: isLoadingUsers, userProfiles } = useSuggestUsers(searchTerm); + + const [selectedAssignees, setSelectedAssignees] = useState([]); + + const existingAssigneesIds = useMemo( + () => + intersection( + ...alertItems.map( + (item) => + item.data.find((data) => data.field === ALERT_WORKFLOW_ASSIGNEE_IDS)?.value ?? [] + ) + ), + [alertItems] + ); + useEffect(() => { + if (isLoadingUsers) { + return; + } + const assignees = userProfiles.filter((user) => existingAssigneesIds.includes(user.uid)); + setSelectedAssignees(assignees); + }, [existingAssigneesIds, isLoadingUsers, userProfiles]); + + const onAssigneesUpdate = useCallback(async () => { + const existingIds = existingAssigneesIds; + const updatedIds = selectedAssignees.map((user) => user?.uid); + + const assigneesToAddArray = updatedIds.filter((uid) => !existingIds.includes(uid)); + const assigneesToRemoveArray = existingIds.filter((uid) => !updatedIds.includes(uid)); + if (assigneesToAddArray.length === 0 && assigneesToRemoveArray.length === 0) { + closePopoverMenu(); + return; + } + + const ids = alertItems.map((item) => item._id); + const assignees = { + assignees_to_add: assigneesToAddArray, + assignees_to_remove: assigneesToRemoveArray, + }; + const onSuccess = () => { + if (refetchQuery) refetchQuery(); + if (refresh) refresh(); + if (clearSelection) clearSelection(); + }; + if (onSubmit != null) { + closePopoverMenu(); + await onSubmit(assignees, ids, onSuccess, setIsLoading); + } + }, [ + alertItems, + clearSelection, + closePopoverMenu, + existingAssigneesIds, + onSubmit, + refetchQuery, + refresh, + selectedAssignees, + setIsLoading, + ]); + + const handleSelectedAssignees = useCallback( + (newAssignees: UserProfileWithAvatar[]) => { + if (!isEqual(newAssignees, selectedAssignees)) { + setSelectedAssignees(newAssignees); + } + }, + [selectedAssignees] + ); + + const selectedStatusMessage = useCallback( + (selectedCount: number) => i18n.ALERT_TOTAL_ASSIGNEES_FILTERED(selectedCount), + [] + ); + + return ( +
+ { + setSearchTerm(term); + }} + selectedStatusMessage={selectedStatusMessage} + options={userProfiles} + selectedOptions={selectedAssignees} + isLoading={isLoadingUsers} + height={'full'} + searchPlaceholder={i18n.ALERT_ASSIGNEES_SEARCH_USERS} + clearButtonLabel={i18n.ALERT_ASSIGNEES_CLEAR_FILTERS} + singleSelection={false} + nullOptionLabel={i18n.ALERT_ASSIGNEES_NO_ASSIGNEES} + /> + + {i18n.ALERT_ASSIGNEES_APPLY_BUTTON_MESSAGE} + +
+ ); +}; + +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..30df492ee6aa2 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,65 @@ 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_APPLY_BUTTON_MESSAGE = i18n.translate( + 'xpack.securitySolution.bulkActions.alertAssigneesApplyButtonMessage', + { + defaultMessage: 'Apply assignees', + } +); + +export const ALERT_ASSIGNEES_CONTEXT_MENU_ITEM_TITLE = i18n.translate( + 'xpack.securitySolution.bulkActions.alertAssigneesContextMenuItemTitle', + { + defaultMessage: 'Apply alert assignees', + } +); + +export const ALERT_ASSIGNEES_CONTEXT_MENU_ITEM_TOOLTIP_INFO = i18n.translate( + 'xpack.securitySolution.bulkActions.alertAssigneesContextMenuItemTooltip', + { + defaultMessage: 'Change alert assignees options in Kibana Advanced Settings.', + } +); + +export const ALERT_TOTAL_ASSIGNEES_FILTERED = (total: number) => + i18n.translate('xpack.securitySolution.bulkActions.totalFilteredUsers', { + defaultMessage: '{total, plural, one {# filter} other {# filters}} selected', + values: { total }, + }); + +export const ALERT_ASSIGNEES_SEARCH_USERS = i18n.translate( + 'xpack.securitySolution.bulkActions.userProfile.selectableSearchPlaceholder', + { + defaultMessage: 'Search users', + } +); + +export const ALERT_ASSIGNEES_CLEAR_FILTERS = i18n.translate( + 'xpack.securitySolution.bulkActions.userProfile.clearFilters', + { + defaultMessage: 'Clear filters', + } +); + +export const ALERT_ASSIGNEES_NO_ASSIGNEES = i18n.translate( + 'xpack.securitySolution.bulkActions.userProfile.noAssigneesLabel', + { + defaultMessage: 'No assignees', + } +); 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..7a87744b49190 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items.test.tsx @@ -0,0 +1,108 @@ +/* + * 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 { UserProfileWithAvatar } from '@kbn/user-profile-components'; +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 { useSuggestUsers } from '../../../../detections/containers/detection_engine/alerts/use_suggest_users'; + +jest.mock('./use_set_alert_assignees'); +jest.mock('../../../../detections/containers/detection_engine/alerts/use_suggest_users'); + +const mockUserProfiles: UserProfileWithAvatar[] = [ + { uid: 'user-id-1', enabled: true, user: { username: 'fakeUser1' }, data: {} }, + { uid: 'user-id-2', enabled: true, user: { username: 'fakeUser2' }, data: {} }, +]; + +const defaultProps: UseBulkAlertAssigneesItemsProps = { + refetch: () => {}, +}; + +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()); + (useSuggestUsers as jest.Mock).mockReturnValue({ + loading: false, + userProfiles: mockUserProfiles, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render alert assignees actions', () => { + const { result } = renderHook(() => useBulkAlertAssigneesItems(defaultProps), { + wrapper: TestProviders, + }); + expect(result.current.alertAssigneesItems.length).toEqual(1); + expect(result.current.alertAssigneesPanels.length).toEqual(1); + + expect(result.current.alertAssigneesItems[0]['data-test-subj']).toEqual( + 'alert-assignees-context-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('alert-assignees-update-button')); + }); + expect(mockSetAlertAssignees).toHaveBeenCalled(); + }); +}); 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..07db301074aae --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items.tsx @@ -0,0 +1,100 @@ +/* + * 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 { EuiFlexGroup, EuiIconTip, EuiFlexItem } from '@elastic/eui'; +import type { RenderContentPanelProps } from '@kbn/triggers-actions-ui-plugin/public/types'; +import React, { useCallback, useMemo } from 'react'; +import { BulkAlertAssigneesPanel } from './alert_bulk_assignees'; +import * as i18n from './translations'; +import { useSetAlertAssignees } from './use_set_alert_assignees'; + +export interface UseBulkAlertAssigneesItemsProps { + refetch?: () => void; +} + +export interface UseBulkAlertAssigneesPanel { + id: number; + title: JSX.Element; + 'data-test-subj': string; + renderContent: (props: RenderContentPanelProps) => JSX.Element; +} + +export const useBulkAlertAssigneesItems = ({ refetch }: UseBulkAlertAssigneesItemsProps) => { + const setAlertAssignees = useSetAlertAssignees(); + const handleOnAlertAssigneesSubmit = useCallback( + async (assignees, ids, onSuccess, setIsLoading) => { + if (setAlertAssignees) { + await setAlertAssignees(assignees, ids, onSuccess, setIsLoading); + } + }, + [setAlertAssignees] + ); + + const alertAssigneesItems = [ + { + 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, + }, + ]; + + const TitleContent = useMemo( + () => ( + + {i18n.ALERT_ASSIGNEES_CONTEXT_MENU_ITEM_TITLE} + + + + + ), + [] + ); + + const renderContent = useCallback( + ({ + alertItems, + refresh, + setIsBulkActionsLoading, + clearSelection, + closePopoverMenu, + }: RenderContentPanelProps) => ( + + ), + [handleOnAlertAssigneesSubmit, refetch] + ); + + const alertAssigneesPanels: UseBulkAlertAssigneesPanel[] = useMemo( + () => [ + { + id: 2, + title: TitleContent, + 'data-test-subj': 'alert-assignees-context-menu-panel', + renderContent, + }, + ], + [TitleContent, 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/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/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 b180856da2b29..c0350f54d152c 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 @@ -101,6 +101,7 @@ const markAsClosedButton = '[data-test-subj="close-alert-status"]'; const addEndpointEventFilterButton = '[data-test-subj="add-event-filter-menu-item"]'; const openAlertDetailsPageButton = '[data-test-subj="open-alert-details-page-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', () => { @@ -338,4 +339,16 @@ describe('Alert table context menu', () => { expect(wrapper.find(applyAlertTagsButton).first().exists()).toEqual(true); }); }); + + describe('Apply alert assignees action', () => { + test('it renders the apply alert assignees 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 a05c351f3d22d..378072ac1f981 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 @@ -47,6 +47,7 @@ import type { Rule } from '../../../../detection_engine/rule_management/logic/ty import { useOpenAlertDetailsAction } from './use_open_alert_details'; import type { AlertTableContextMenuItem } from '../types'; import { useAlertTagsActions } from './use_alert_tags_actions'; +import { useAlertAssigneesActions } from './use_alert_assignees_actions'; interface AlertContextMenuProps { ariaLabel?: string; @@ -228,6 +229,12 @@ const AlertContextMenuComponent: React.FC !isEvent && ruleId @@ -235,6 +242,7 @@ const AlertContextMenuComponent: React.FC { 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..5110f512a4610 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_assignees_actions.test.tsx @@ -0,0 +1,177 @@ +/* + * 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 type { UserProfileWithAvatar } from '@kbn/user-profile-components'; +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 { useSuggestUsers } from '../../../containers/detection_engine/alerts/use_suggest_users'; + +jest.mock('../../../containers/detection_engine/alerts/use_alerts_privileges'); +jest.mock('../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees'); +jest.mock('../../../containers/detection_engine/alerts/use_suggest_users'); + +const mockUserProfiles: UserProfileWithAvatar[] = [ + { 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, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render alert assignees actions', () => { + const { result } = renderHook(() => useAlertAssigneesActions(defaultProps), { + wrapper: TestProviders, + }); + expect(result.current.alertAssigneesItems.length).toEqual(1); + expect(result.current.alertAssigneesPanels.length).toEqual(1); + + expect(result.current.alertAssigneesItems[0]['data-test-subj']).toEqual( + 'alert-assignees-context-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 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(1); + 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()); + (useSuggestUsers as jest.Mock).mockReturnValue({ + loading: false, + userProfiles: 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..087c8bbd04981 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_assignees_actions.tsx @@ -0,0 +1,81 @@ +/* + * 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 { EuiContextMenuPanelDescriptor } from '@elastic/eui'; +import { useMemo } from 'react'; + +import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; +import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils'; +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 { alertAssigneesItems, alertAssigneesPanels } = useBulkAlertAssigneesItems({ + refetch, + }); + + const itemsToReturn: AlertTableContextMenuItem[] = useMemo( + () => + alertAssigneesItems.map((item) => ({ + name: item.name, + panel: item.panel, + 'data-test-subj': item['data-test-subj'], + key: item.key, + })), + [alertAssigneesItems] + ); + + const panelsToReturn: EuiContextMenuPanelDescriptor[] = useMemo( + () => + alertAssigneesPanels.map((panel) => { + const content = panel.renderContent({ + closePopoverMenu: closePopover, + setIsBulkActionsLoading: () => {}, + alertItems: alertAssigneeData, + }); + return { title: panel.title, content, id: panel.id }; + }), + [alertAssigneeData, alertAssigneesPanels, closePopover] + ); + + 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 5115bf0130e6d..6b69bb5f8724c 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/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/render_cell_value.tsx b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx index 47be8b0739346..ae4e2428f5e86 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 @@ -6,13 +6,21 @@ */ import type { EuiDataGridCellValueElementProps } from '@elastic/eui'; -import { EuiIcon, EuiToolTip, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { + EuiIcon, + EuiToolTip, + EuiFlexGroup, + EuiFlexItem, + EuiNotificationBadge, + EuiLoadingSpinner, +} from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; import type { GetRenderCellValue } from '@kbn/triggers-actions-ui-plugin/public'; import { find, getOr } from 'lodash/fp'; import type { TimelineNonEcsData } from '@kbn/timelines-plugin/common'; import { tableDefaults, dataTableSelectors } from '@kbn/securitysolution-data-table'; import type { TableId } from '@kbn/securitysolution-data-table'; +import { UserAvatar } from '@kbn/user-profile-components'; import { useLicense } from '../../../common/hooks/use_license'; import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; import { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers'; @@ -23,7 +31,10 @@ import { AlertsCasesTourSteps, SecurityStepId, } from '../../../common/components/guided_onboarding_tour/tour_config'; -import { SIGNAL_RULE_NAME_FIELD_NAME } from '../../../timelines/components/timeline/body/renderers/constants'; +import { + SIGNAL_ASSIGNEE_IDS_FIELD_NAME, + SIGNAL_RULE_NAME_FIELD_NAME, +} from '../../../timelines/components/timeline/body/renderers/constants'; import { useSourcererDataView } from '../../../common/containers/sourcerer'; import type { CellValueElementProps } from '../../../timelines/components/timeline/cell_rendering'; @@ -33,6 +44,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 { useGetUserProfiles } from '../../containers/detection_engine/alerts/use_get_user_profiles'; /** * This implementation of `EuiDataGrid`'s `renderCellValue` @@ -62,6 +74,49 @@ export const RenderCellValue: React.FC { + const ecsAssignees = props.ecsData?.kibana?.alert.workflow_assignee_ids; + const dataAssignees = find({ field: 'kibana.alert.workflow_assignee_ids' }, props.data) as + | string[] + | undefined; + return ecsAssignees ?? dataAssignees ?? []; + }, [props.data, props.ecsData?.kibana?.alert.workflow_assignee_ids]); + const { loading: isLoadingProfiles, userProfiles } = useGetUserProfiles(actualAssignees); + const assignees = userProfiles?.filter((user) => actualAssignees.includes(user.uid)) ?? []; + if ( + columnId === SIGNAL_ASSIGNEE_IDS_FIELD_NAME && + (actualAssignees.length || isLoadingProfiles) + ) { + // Show spinner if loading profiles or if there are no fetched profiles yet + if (isLoadingProfiles || !assignees.length) { + return ; + } + return ( + + {assignees.length > 2 ? ( + ( +
{user.user.email ?? user.user.username}
+ ))} + repositionOnScroll={true} + > + {assignees.length} +
+ ) : ( + assignees.map((user) => ( + + )) + )} +
+ ); + } + const component = ( ); }, - [browserFieldsByName, browserFields, columnHeaders] + [browserFieldsByName, columnHeaders, browserFields] ); return result; }; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/__mocks__/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/__mocks__/api.ts index d2eda8a8762e1..80eef1e998956 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/__mocks__/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/__mocks__/api.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; import type { QueryAlerts, AlertSearchResponse, @@ -13,7 +14,13 @@ import type { Privilege, CasesFromAlertsResponse, } from '../types'; -import { alertsMock, mockSignalIndex, mockUserPrivilege, mockCaseIdsFromAlertId } from '../mock'; +import { + alertsMock, + mockSignalIndex, + mockUserPrivilege, + mockCaseIdsFromAlertId, + mockUserProfiles, +} from '../mock'; export const fetchQueryAlerts = async ({ query, @@ -36,3 +43,9 @@ export const getCaseIdsFromAlertId = async ({ }: { alertId: string; }): Promise => Promise.resolve(mockCaseIdsFromAlertId); + +export const suggestUsers = async ({ + searchTerm, +}: { + searchTerm: string; +}): Promise => Promise.resolve(mockUserProfiles); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.test.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.test.ts index 92801daeba514..13c2cd1bafeed 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.test.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.test.ts @@ -13,6 +13,7 @@ import { mockSignalIndex, mockUserPrivilege, mockHostIsolation, + mockUserProfiles, } from './mock'; import { fetchQueryAlerts, @@ -22,6 +23,7 @@ import { createHostIsolation, updateAlertStatusByQuery, updateAlertStatusByIds, + suggestUsers, } from './api'; import { coreMock } from '@kbn/core/public/mocks'; @@ -264,4 +266,28 @@ describe('Detections Alerts API', () => { expect(hostIsolationResponse).toEqual(mockHostIsolation); }); }); + + describe('suggestUsers', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(mockUserProfiles); + }); + + test('check parameter url', async () => { + await suggestUsers({ searchTerm: 'name1' }); + expect(fetchMock).toHaveBeenCalledWith( + '/api/detection_engine/signals/suggest_users', + 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/detections/containers/detection_engine/alerts/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts index ecd53bbf76a89..3959d6f922625 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts @@ -7,6 +7,7 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { getCasesFromAlertsUrl } from '@kbn/cases-plugin/common'; +import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; import type { ResponseActionApiResponse, HostInfo } from '../../../../../common/endpoint/types'; import { DETECTION_ENGINE_QUERY_SIGNALS_URL, @@ -15,6 +16,7 @@ import { DETECTION_ENGINE_PRIVILEGES_URL, ALERTS_AS_DATA_FIND_URL, DETECTION_ENGINE_ALERTS_INDEX_URL, + DETECTION_ENGINE_ALERT_SUGGEST_USERS_URL, } from '../../../../../common/constants'; import { HOST_METADATA_GET_ROUTE } from '../../../../../common/endpoint/constants'; import { KibanaServices } from '../../../../common/lib/kibana'; @@ -28,6 +30,7 @@ import type { CasesFromAlertsResponse, CheckSignalIndex, UpdateAlertStatusByIdsProps, + SuggestUsersProps, } from './types'; import { isolateHost, unIsolateHost } from '../../../../common/lib/endpoint_isolation'; import { resolvePathVariables } from '../../../../common/utils/resolve_path_variables'; @@ -256,3 +259,19 @@ export const getHostMetadata = async ({ resolvePathVariables(HOST_METADATA_GET_ROUTE, { id: agentId }), { method: 'GET', signal, version: '2023-10-31' } ); + +/** + * 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/detections/containers/detection_engine/alerts/mock.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/mock.ts index 8782f9ecad631..94d6112c3adbe 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/mock.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/mock.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; import type { HostIsolationResponse } from '../../../../../common/endpoint/types/actions'; import type { AlertSearchResponse, AlertsIndex, Privilege, CasesFromAlertsResponse } from './types'; @@ -1334,3 +1335,8 @@ export const mockCaseIdsFromAlertId: CasesFromAlertsResponse = [ { id: '818601a0-b26b-11eb-8759-6b318e8cf4bc', title: 'Case 1' }, { id: '8a774850-b26b-11eb-8759-6b318e8cf4bc', title: 'Case 2' }, ]; + +export const mockUserProfiles: UserProfileWithAvatar[] = [ + { 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/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/containers/detection_engine/alerts/types.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/types.ts index 0f89ac8f451b5..4ee41993995d9 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/types.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/types.ts @@ -118,3 +118,7 @@ export interface Privilege { is_authenticated: boolean; has_encryption_key: boolean; } + +export interface SuggestUsersProps { + searchTerm: string; +} diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_get_user_profiles.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_get_user_profiles.test.tsx new file mode 100644 index 0000000000000..709dd27e1b82b --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_get_user_profiles.test.tsx @@ -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 { renderHook } from '@testing-library/react-hooks'; +import { useGetUserProfiles } from './use_get_user_profiles'; +import { useKibana } from '../../../../common/lib/kibana'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import { useAppToastsMock } from '../../../../common/hooks/use_app_toasts.mock'; +import { mockUserProfiles } from './mock'; +import { createStartServicesMock } from '../../../../common/lib/kibana/kibana_react.mock'; +import { securityMock } from '@kbn/security-plugin/public/mocks'; + +jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../../common/hooks/use_app_toasts'); + +describe('useGetUserProfiles 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 = ['user1']; + const { result, waitForNextUpdate } = renderHook(() => useGetUserProfiles(assigneesIds)); + await waitForNextUpdate(); + + expect(spyOnUserProfiles).toHaveBeenCalledTimes(1); + expect(result.current).toEqual({ + loading: false, + userProfiles: mockUserProfiles, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_get_user_profiles.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_get_user_profiles.tsx new file mode 100644 index 0000000000000..bea9301bdbdd1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_get_user_profiles.tsx @@ -0,0 +1,55 @@ +/* + * 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 { useEffect, useState } from 'react'; +import { useKibana } from '../../../../common/lib/kibana'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import { USER_PROFILES_FAILURE } from './translations'; + +interface GetUserProfilesReturn { + loading: boolean; + userProfiles: UserProfileWithAvatar[]; +} + +export const useGetUserProfiles = (userIds: string[]): GetUserProfilesReturn => { + const [loading, setLoading] = useState(false); + const [users, setUsers] = useState([]); + const { addError } = useAppToasts(); + const userProfiles = useKibana().services.security.userProfiles; + + useEffect(() => { + // isMounted tracks if a component is mounted before changing state + let isMounted = true; + setLoading(true); + const fetchData = async () => { + try { + const profiles = + userIds.length > 0 + ? await userProfiles.bulkGet({ + uids: new Set(userIds), + dataPath: 'avatar', + }) + : []; + if (isMounted) { + setUsers(profiles); + } + } catch (error) { + addError(error.message, { title: USER_PROFILES_FAILURE }); + } + if (isMounted) { + setLoading(false); + } + }; + fetchData(); + return () => { + // updates to show component is unmounted + isMounted = false; + }; + }, [addError, userProfiles, userIds]); + return { loading, userProfiles: users }; +}; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_suggest_users.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_suggest_users.test.tsx new file mode 100644 index 0000000000000..f22e0cdd8f59a --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_suggest_users.test.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { useSuggestUsers } from './use_suggest_users'; +import * as api from './api'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import { useAppToastsMock } from '../../../../common/hooks/use_app_toasts.mock'; +import { mockUserProfiles } from './mock'; + +jest.mock('./api'); +jest.mock('../../../../common/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('')); + await waitForNextUpdate(); + expect(spyOnUserProfiles).toHaveBeenCalledTimes(1); + expect(result.current).toEqual({ + loading: false, + userProfiles: mockUserProfiles, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_suggest_users.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_suggest_users.tsx new file mode 100644 index 0000000000000..3d44d1ef5596e --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_suggest_users.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 { UserProfileWithAvatar } from '@kbn/user-profile-components'; +import { useEffect, useState } from 'react'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import { suggestUsers } from './api'; +import { USER_PROFILES_FAILURE } from './translations'; + +interface SuggestUsersReturn { + loading: boolean; + userProfiles: UserProfileWithAvatar[]; +} + +export const useSuggestUsers = (searchTerm: string): SuggestUsersReturn => { + const [loading, setLoading] = useState(false); + const [users, setUsers] = useState([]); + const { addError } = useAppToasts(); + + useEffect(() => { + // isMounted tracks if a component is mounted before changing state + let isMounted = true; + setLoading(true); + const fetchData = async () => { + try { + const usersResponse = await suggestUsers({ searchTerm }); + if (isMounted) { + setUsers(usersResponse); + } + } catch (error) { + addError(error.message, { title: USER_PROFILES_FAILURE }); + } + if (isMounted) { + setLoading(false); + } + }; + fetchData(); + return () => { + // updates to show component is unmounted + isMounted = false; + }; + }, [addError, searchTerm]); + return { loading, userProfiles: 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..495f3eedeaaa7 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 @@ -13,6 +13,7 @@ import type { Filter } from '@kbn/es-query'; import { useCallback } from 'react'; import type { TableId } from '@kbn/securitysolution-data-table'; import { useBulkAlertTagsItems } from '../../../common/components/toolbar/bulk_actions/use_bulk_alert_tags_items'; +import { useBulkAlertAssigneesItems } from '../../../common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items'; import type { inputsModel, State } from '../../../common/store'; import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; import { inputsSelectors } from '../../../common/store'; @@ -93,7 +94,11 @@ export const getBulkActionHook = refetch: refetchGlobalQuery, }); - const items = [...alertActions, timelineAction, ...alertTagsItems]; + const { alertAssigneesItems, alertAssigneesPanels } = useBulkAlertAssigneesItems({ + refetch: 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/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/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..ce563ae0384f3 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,16 @@ export const validateAlertTagsArrays = (tags: AlertTags, ids: string[]) => { } return validationErrors; }; + +export const validateAlertAssigneesArrays = (assignees: AlertAssignees, ids: string[]) => { + const validationErrors = []; + if (ids.length === 0) { + validationErrors.push(i18n.NO_IDS_VALIDATION_ERROR); + } + const { assignees_to_add: assigneesToAdd, assignees_to_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..c92fa9a70c86d --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_assignees_route.test.ts @@ -0,0 +1,115 @@ +/* + * 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 assignees_to_add and assignees_to_remove parameters.`, + ], + status_code: 400, + }); + }); + + test('returns 400 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']), + }); + + 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: [`No alert ids were provided`], + status_code: 400, + }); + }); + }); + + 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..4498041e54286 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_assignees_route.ts @@ -0,0 +1,123 @@ +/* + * 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 type { SetAlertAssigneesRequestBodyDecoded } from '../../../../../common/api/detection_engine/alert_assignees'; +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 { buildRouteValidation } 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: buildRouteValidation< + typeof setAlertAssigneesRequestBody, + SetAlertAssigneesRequestBodyDecoded + >(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 siemClient = securitySolution?.getAppClient(); + const siemResponse = buildSiemResponse(response); + const validationErrors = validateAlertAssigneesArrays(assignees, ids); + const spaceId = securitySolution?.getSpaceId() ?? 'default'; + + if (validationErrors.length) { + return siemResponse.error({ statusCode: 400, body: validationErrors }); + } + + if (!siemClient) { + return siemResponse.error({ statusCode: 404 }); + } + + const assigneesToAdd = uniq(assignees.assignees_to_add); + const assigneesToRemove = uniq(assignees.assignees_to_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: false, + 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..704e06e96e5bb 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 assignees_to_add and assignees_to_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..6b48dfcf84380 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/users/suggest_user_profiles_route.ts @@ -0,0 +1,73 @@ +/* + * 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 { buildRouteValidation } from '../../../../utils/build_validation/route_validation'; + +import type { SuggestUserProfilesRequestQueryDecoded } from '../../../../../common/api/detection_engine/users'; +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: buildRouteValidation< + typeof suggestUserProfilesRequestQuery, + SuggestUserProfilesRequestQueryDecoded + >(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/routes/index.ts b/x-pack/plugins/security_solution/server/routes/index.ts index d3786ea8acb88..180a30f6da29a 100644 --- a/x-pack/plugins/security_solution/server/routes/index.ts +++ b/x-pack/plugins/security_solution/server/routes/index.ts @@ -74,6 +74,7 @@ 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 { riskScorePreviewRoute, riskEngineDisableRoute, @@ -82,6 +83,7 @@ import { riskEngineStatusRoute, } from '../lib/risk_engine/routes'; import { riskScoreCalculationRoute } from '../lib/risk_engine/routes/risk_score_calculation_route'; +import { suggestUserProfilesRoute } from '../lib/detection_engine/routes/users/suggest_user_profiles_route'; export const initRoutes = ( router: SecuritySolutionPluginRouter, @@ -144,11 +146,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/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 e028ae1867a8e..c59c4cade2aca 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 @@ -393,6 +393,10 @@ describe('AlertsTable.BulkActions', () => { field: 'kibana.alert.workflow_tags', value: [], }, + { + field: 'kibana.alert.workflow_assignee_ids', + value: [], + }, ], ecs: { _id: 'alert0', @@ -640,6 +644,10 @@ describe('AlertsTable.BulkActions', () => { field: 'kibana.alert.workflow_tags', value: [], }, + { + field: 'kibana.alert.workflow_assignee_ids', + value: [], + }, ], ecs: { _id: 'alert1', @@ -868,6 +876,10 @@ describe('AlertsTable.BulkActions', () => { field: 'kibana.alert.workflow_tags', value: [], }, + { + field: 'kibana.alert.workflow_assignee_ids', + value: [], + }, ], ecs: { _id: 'alert0', @@ -894,6 +906,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/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/esql.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/esql.ts index daac0f6c17ddd..a290f064295a8 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/esql.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/esql.ts @@ -150,6 +150,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_cypress/cypress/e2e/detection_alerts/alert_assignees.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_alerts/alert_assignees.cy.ts new file mode 100644 index 0000000000000..a281df40ac994 --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_alerts/alert_assignees.cy.ts @@ -0,0 +1,75 @@ +/* + * 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 { getNewRule } from '../../objects/rule'; +import { + clickAlertAssignee, + findSelectedAlertAssignee, + findUnselectedAlertAssignee, + openAlertAssigningBulkActionMenu, + selectNumberOfAlerts, + updateAlertAssignees, +} from '../../tasks/alerts'; +import { createRule } from '../../tasks/api_calls/rules'; +import { cleanKibana, deleteAlertsAndRules } from '../../tasks/common'; +import { login } from '../../tasks/login'; +import { visitWithTimeRange } from '../../tasks/navigation'; +import { ALERTS_URL } from '../../urls/navigation'; +import { waitForAlertsToPopulate } from '../../tasks/create_new_rule'; +import { ALERTS_TABLE_ROW_LOADER } from '../../screens/alerts'; +import { + waitForAssigneesToPopulatePopover, + waitForAssigneeToAppearInTable, + waitForAssigneeToDisappearInTable, +} from '../../tasks/alert_assignees'; + +describe('Alert assigning', { tags: ['@ess', '@serverless'] }, () => { + before(() => { + cleanKibana(); + cy.task('esArchiverResetKibana'); + }); + + beforeEach(() => { + login(); + deleteAlertsAndRules(); + cy.task('esArchiverLoad', { archiveName: 'endpoint' }); + createRule(getNewRule({ rule_id: 'new custom rule' })); + visitWithTimeRange(ALERTS_URL); + waitForAlertsToPopulate(); + }); + + afterEach(() => { + cy.task('esArchiverUnload', 'endpoint'); + }); + + it('Add and remove an assignee using the alert bulk action menu', () => { + const userName = Cypress.env('ELASTICSEARCH_USERNAME'); + + // Add an assignee to one alert + selectNumberOfAlerts(1); + openAlertAssigningBulkActionMenu(); + waitForAssigneesToPopulatePopover(); + clickAlertAssignee(userName); + updateAlertAssignees(); + cy.get(ALERTS_TABLE_ROW_LOADER).should('not.exist'); + waitForAssigneeToAppearInTable(userName); + selectNumberOfAlerts(1); + openAlertAssigningBulkActionMenu(); + waitForAssigneesToPopulatePopover(); + findSelectedAlertAssignee(userName); + + // Remove assignee from that alert + clickAlertAssignee(userName); + updateAlertAssignees(); + cy.get(ALERTS_TABLE_ROW_LOADER).should('not.exist'); + waitForAssigneeToDisappearInTable(userName); + selectNumberOfAlerts(1); + openAlertAssigningBulkActionMenu(); + waitForAssigneesToPopulatePopover(); + findUnselectedAlertAssignee(userName); + }); +}); 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 86a1703959ea9..257180258c535 100644 --- a/x-pack/test/security_solution_cypress/cypress/screens/alerts.ts +++ b/x-pack/test/security_solution_cypress/cypress/screens/alerts.ts @@ -204,3 +204,16 @@ 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_ASSIGNING_CONTEXT_MENU_ITEM = + '[data-test-subj="alert-assignees-context-menu-item"]'; + +export const ALERT_ASSIGNING_SELECTABLE_MENU_ITEM = + '[data-test-subj="alert-assignees-selectable-menu"]'; + +export const ALERT_ASSIGNING_CONTEXT_MENU = '[data-test-subj="alert-assignees-selectable-menu"]'; + +export const ALERT_ASSIGNING_UPDATE_BUTTON = '[data-test-subj="alert-assignees-update-button"]'; + +export const ALERT_ASSIGNING_USER_AVATAR = (assignee: string) => + `[data-test-subj="alertTableAssigneeAvatar"][title='${assignee}']`; diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/alert_assignees.ts b/x-pack/test/security_solution_cypress/cypress/tasks/alert_assignees.ts new file mode 100644 index 0000000000000..8e89bc3e2d52c --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/tasks/alert_assignees.ts @@ -0,0 +1,55 @@ +/* + * 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_ASSIGNING_UPDATE_BUTTON, ALERT_ASSIGNING_USER_AVATAR } from '../screens/alerts'; + +export const waitForAssigneesToPopulatePopover = () => { + cy.waitUntil( + () => { + cy.log('Waiting for assignees to appear in popover'); + return cy.root().then(($el) => { + const $updateButton = $el.find(ALERT_ASSIGNING_UPDATE_BUTTON); + return !$updateButton.prop('disabled'); + }); + }, + { interval: 500, timeout: 12000 } + ); +}; + +export const waitForAssigneeToAppearInTable = (userName: string) => { + cy.reload(); + cy.waitUntil( + () => { + cy.log('Waiting for assignees to appear in the "Assignees" column'); + return cy.root().then(($el) => { + const assigneesState = $el.find(`.euiAvatar${ALERT_ASSIGNING_USER_AVATAR(userName)}`); + if (assigneesState.length > 0) { + return true; + } + return false; + }); + }, + { interval: 500, timeout: 12000 } + ); +}; + +export const waitForAssigneeToDisappearInTable = (userName: string) => { + cy.reload(); + cy.waitUntil( + () => { + cy.log('Waiting for assignees to disappear in the "Assignees" column'); + return cy.root().then(($el) => { + const assigneesState = $el.find(`.euiAvatar${ALERT_ASSIGNING_USER_AVATAR(userName)}`); + if (assigneesState.length > 0) { + return false; + } + return true; + }); + }, + { interval: 500, timeout: 12000 } + ); +}; diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/alerts.ts b/x-pack/test/security_solution_cypress/cypress/tasks/alerts.ts index 1bbdc9eac1539..1b4177ff3a874 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/alerts.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/alerts.ts @@ -47,6 +47,10 @@ import { ALERTS_HISTOGRAM_LEGEND, LEGEND_ACTIONS, SESSION_VIEWER_BUTTON, + ALERT_ASSIGNING_CONTEXT_MENU_ITEM, + ALERT_ASSIGNING_CONTEXT_MENU, + ALERT_ASSIGNING_SELECTABLE_MENU_ITEM, + ALERT_ASSIGNING_UPDATE_BUTTON, ALERT_TAGGING_CONTEXT_MENU_ITEM, ALERT_TAGGING_CONTEXT_MENU, ALERT_TAGGING_UPDATE_BUTTON, @@ -508,3 +512,30 @@ export const switchAlertTableToGridView = () => { cy.get(ALERT_TABLE_SUMMARY_VIEW_SELECTABLE).should('be.visible').trigger('click'); cy.get(ALERT_TABLE_GRID_VIEW_OPTION).should('be.visible').trigger('click'); }; + +export const openAlertAssigningBulkActionMenu = () => { + cy.get(TAKE_ACTION_POPOVER_BTN).click(); + cy.get(ALERT_ASSIGNING_CONTEXT_MENU_ITEM).click(); +}; + +export const clickAlertAssignee = (assignee: string) => { + cy.get(ALERT_ASSIGNING_CONTEXT_MENU).contains(assignee).click(); +}; + +export const updateAlertAssignees = () => { + cy.get(ALERT_ASSIGNING_UPDATE_BUTTON).click(); +}; + +export const findSelectedAlertAssignee = (assignee: string) => { + cy.get(ALERT_ASSIGNING_SELECTABLE_MENU_ITEM) + .find('[aria-checked="true"]') + .first() + .contains(assignee); +}; + +export const findUnselectedAlertAssignee = (assignee: string) => { + cy.get(ALERT_ASSIGNING_SELECTABLE_MENU_ITEM) + .find('[aria-checked="false"]') + .first() + .contains(assignee); +}; From 6a89c84a29c27418aef9caf3e744f397ab09188c Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Thu, 19 Oct 2023 13:04:01 +0200 Subject: [PATCH 03/53] Fix bug where we would apply only visible selection during the search (#169367) ## Summary Fixes the bug where we would apply only visible user profile selections instead of taking into account those which are not visible during the search within component. --- .../bulk_actions/alert_bulk_assignees.tsx | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) 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 index c720c70a8b4aa..9565054eab874 100644 --- 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 @@ -13,6 +13,7 @@ import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils'; import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; import { UserProfilesSelectable } from '@kbn/user-profile-components'; +import { useGetUserProfiles } from '../../../../detections/containers/detection_engine/alerts/use_get_user_profiles'; import { useSuggestUsers } from '../../../../detections/containers/detection_engine/alerts/use_suggest_users'; import * as i18n from './translations'; import type { SetAlertAssigneesFunc } from './use_set_alert_assignees'; @@ -40,7 +41,7 @@ const BulkAlertAssigneesPanelComponent: React.FC([]); - const existingAssigneesIds = useMemo( + const originalIds = useMemo( () => intersection( ...alertItems.map( @@ -50,20 +51,21 @@ const BulkAlertAssigneesPanelComponent: React.FC { - if (isLoadingUsers) { + if (isLoadingAssignedUserProfiles) { return; } - const assignees = userProfiles.filter((user) => existingAssigneesIds.includes(user.uid)); - setSelectedAssignees(assignees); - }, [existingAssigneesIds, isLoadingUsers, userProfiles]); + setSelectedAssignees(assignedUserProfiles); + }, [assignedUserProfiles, isLoadingAssignedUserProfiles]); const onAssigneesUpdate = useCallback(async () => { - const existingIds = existingAssigneesIds; const updatedIds = selectedAssignees.map((user) => user?.uid); - const assigneesToAddArray = updatedIds.filter((uid) => !existingIds.includes(uid)); - const assigneesToRemoveArray = existingIds.filter((uid) => !updatedIds.includes(uid)); + const assigneesToAddArray = updatedIds.filter((uid) => !originalIds.includes(uid)); + const assigneesToRemoveArray = originalIds.filter((uid) => !updatedIds.includes(uid)); if (assigneesToAddArray.length === 0 && assigneesToRemoveArray.length === 0) { closePopoverMenu(); return; @@ -87,7 +89,7 @@ const BulkAlertAssigneesPanelComponent: React.FC Date: Thu, 19 Oct 2023 16:55:13 +0200 Subject: [PATCH 04/53] Fix broken tests (#169416) ## Summary Fix broken tests introduced in https://github.com/elastic/kibana/pull/169367 --- .../alert_bulk_assignees.test.tsx | 150 +++++++++--------- .../use_bulk_alert_assignees_items.test.tsx | 6 + .../use_alert_assignees_actions.test.tsx | 6 + 3 files changed, 84 insertions(+), 78 deletions(-) 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 index a3a76c4bea327..2d7748cf76691 100644 --- 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 @@ -10,27 +10,57 @@ import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; import { act, fireEvent, render } from '@testing-library/react'; import React from 'react'; import { TestProviders } from '../../../mock'; +import { useGetUserProfiles } from '../../../../detections/containers/detection_engine/alerts/use_get_user_profiles'; import { useSuggestUsers } from '../../../../detections/containers/detection_engine/alerts/use_suggest_users'; import { BulkAlertAssigneesPanel } from './alert_bulk_assignees'; import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils'; +jest.mock('../../../../detections/containers/detection_engine/alerts/use_get_user_profiles'); jest.mock('../../../../detections/containers/detection_engine/alerts/use_suggest_users'); const mockUserProfiles: UserProfileWithAvatar[] = [ - { uid: 'default-test-assignee-id-1', enabled: true, user: { username: 'user1' }, data: {} }, - { uid: 'default-test-assignee-id-2', enabled: true, user: { username: 'user2' }, data: {} }, + { uid: 'user-id-1', enabled: true, user: { username: 'user1' }, data: {} }, + { uid: 'user-id-2', enabled: true, user: { username: 'user2' }, data: {} }, ]; -const mockAssigneeItems = [ +const mockSuggestedUserProfiles: UserProfileWithAvatar[] = [ + ...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: ['assignee-id-1', 'assignee-id-2'] }], + 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' }, }, ]; -(useSuggestUsers as jest.Mock).mockReturnValue({ loading: false, userProfiles: mockUserProfiles }); +(useGetUserProfiles as jest.Mock).mockReturnValue({ + loading: false, + userProfiles: mockUserProfiles, +}); +(useSuggestUsers as jest.Mock).mockReturnValue({ + loading: false, + userProfiles: mockSuggestedUserProfiles, +}); const renderAssigneesMenu = ( items: TimelineItem[], @@ -56,7 +86,7 @@ describe('BulkAlertAssigneesPanel', () => { }); test('it renders', () => { - const wrapper = renderAssigneesMenu(mockAssigneeItems); + const wrapper = renderAssigneesMenu(mockAlertsWithAssignees); expect(wrapper.getByTestId('alert-assignees-update-button')).toBeInTheDocument(); expect(useSuggestUsers).toHaveBeenCalled(); @@ -67,25 +97,8 @@ describe('BulkAlertAssigneesPanel', () => { const mockedOnSubmit = jest.fn(); const mockedSetIsLoading = jest.fn(); - const mockAssignees = [ - { - _id: 'test-id', - data: [{ field: ALERT_WORKFLOW_ASSIGNEE_IDS, value: ['default-test-assignee-id-1'] }], - ecs: { _id: 'test-id' }, - }, - { - _id: 'test-id', - data: [ - { - field: ALERT_WORKFLOW_ASSIGNEE_IDS, - value: ['default-test-assignee-id-1', 'default-test-assignee-id-2'], - }, - ], - ecs: { _id: 'test-id' }, - }, - ]; const wrapper = renderAssigneesMenu( - mockAssignees, + mockAlertsWithAssignees, mockedClosePopover, mockedOnSubmit, mockedSetIsLoading @@ -100,40 +113,32 @@ describe('BulkAlertAssigneesPanel', () => { }); test('it updates state correctly', () => { - const mockAssignees = [ - { - _id: 'test-id', - data: [{ field: ALERT_WORKFLOW_ASSIGNEE_IDS, value: ['default-test-assignee-id-1'] }], - ecs: { _id: 'test-id' }, - }, - { - _id: 'test-id', - data: [ - { - field: ALERT_WORKFLOW_ASSIGNEE_IDS, - value: ['default-test-assignee-id-1', 'default-test-assignee-id-2'], - }, - ], - ecs: { _id: 'test-id' }, - }, - ]; - const wrapper = renderAssigneesMenu(mockAssignees); - - expect(wrapper.getAllByRole('option')[0]).toHaveAttribute('title', 'user1'); - expect(wrapper.getAllByRole('option')[0]).toBeChecked(); - act(() => { - fireEvent.click(wrapper.getByText('user1')); - }); - expect(wrapper.getAllByRole('option')[0]).toHaveAttribute('title', 'user1'); - expect(wrapper.getAllByRole('option')[0]).not.toBeChecked(); - - expect(wrapper.getAllByRole('option')[1]).toHaveAttribute('title', 'user2'); - expect(wrapper.getAllByRole('option')[1]).not.toBeChecked(); - act(() => { - fireEvent.click(wrapper.getByText('user2')); - }); - expect(wrapper.getAllByRole('option')[1]).toHaveAttribute('title', 'user2'); - expect(wrapper.getAllByRole('option')[1]).toBeChecked(); + 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', () => { @@ -141,25 +146,8 @@ describe('BulkAlertAssigneesPanel', () => { const mockedOnSubmit = jest.fn(); const mockedSetIsLoading = jest.fn(); - const mockAssignees = [ - { - _id: 'test-id', - data: [{ field: ALERT_WORKFLOW_ASSIGNEE_IDS, value: ['default-test-assignee-id-1'] }], - ecs: { _id: 'test-id' }, - }, - { - _id: 'test-id', - data: [ - { - field: ALERT_WORKFLOW_ASSIGNEE_IDS, - value: ['default-test-assignee-id-1', 'default-test-assignee-id-2'], - }, - ], - ecs: { _id: 'test-id' }, - }, - ]; const wrapper = renderAssigneesMenu( - mockAssignees, + mockAlertsWithAssignees, mockedClosePopover, mockedOnSubmit, mockedSetIsLoading @@ -170,6 +158,12 @@ describe('BulkAlertAssigneesPanel', () => { act(() => { fireEvent.click(wrapper.getByText('user2')); }); + act(() => { + fireEvent.click(wrapper.getByText('user3')); + }); + act(() => { + fireEvent.click(wrapper.getByText('user4')); + }); act(() => { fireEvent.click(wrapper.getByTestId('alert-assignees-update-button')); @@ -178,8 +172,8 @@ describe('BulkAlertAssigneesPanel', () => { expect(mockedOnSubmit).toHaveBeenCalled(); expect(mockedOnSubmit).toHaveBeenCalledWith( { - assignees_to_add: ['default-test-assignee-id-2'], - assignees_to_remove: ['default-test-assignee-id-1'], + assignees_to_add: ['user-id-4', 'user-id-3'], + assignees_to_remove: ['user-id-1', 'user-id-2'], }, ['test-id', 'test-id'], expect.anything(), // An anonymous callback defined in the onSubmit function 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 index 7a87744b49190..f04ae2aef3d2b 100644 --- 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 @@ -16,9 +16,11 @@ import type { } from './use_bulk_alert_assignees_items'; import { useBulkAlertAssigneesItems } from './use_bulk_alert_assignees_items'; import { useSetAlertAssignees } from './use_set_alert_assignees'; +import { useGetUserProfiles } from '../../../../detections/containers/detection_engine/alerts/use_get_user_profiles'; import { useSuggestUsers } from '../../../../detections/containers/detection_engine/alerts/use_suggest_users'; jest.mock('./use_set_alert_assignees'); +jest.mock('../../../../detections/containers/detection_engine/alerts/use_get_user_profiles'); jest.mock('../../../../detections/containers/detection_engine/alerts/use_suggest_users'); const mockUserProfiles: UserProfileWithAvatar[] = [ @@ -50,6 +52,10 @@ const renderPanel = (panel: UseBulkAlertAssigneesPanel) => { describe('useBulkAlertAssigneesItems', () => { beforeEach(() => { (useSetAlertAssignees as jest.Mock).mockReturnValue(jest.fn()); + (useGetUserProfiles as jest.Mock).mockReturnValue({ + loading: false, + userProfiles: mockUserProfiles, + }); (useSuggestUsers as jest.Mock).mockReturnValue({ loading: false, userProfiles: mockUserProfiles, 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 index 5110f512a4610..a11441ed0f346 100644 --- 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 @@ -17,10 +17,12 @@ 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 { useGetUserProfiles } from '../../../containers/detection_engine/alerts/use_get_user_profiles'; import { useSuggestUsers } from '../../../containers/detection_engine/alerts/use_suggest_users'; jest.mock('../../../containers/detection_engine/alerts/use_alerts_privileges'); jest.mock('../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees'); +jest.mock('../../../containers/detection_engine/alerts/use_get_user_profiles'); jest.mock('../../../containers/detection_engine/alerts/use_suggest_users'); const mockUserProfiles: UserProfileWithAvatar[] = [ @@ -160,6 +162,10 @@ describe('useAlertAssigneesActions', () => { it('should render the nested panel', async () => { (useSetAlertAssignees as jest.Mock).mockReturnValue(jest.fn()); + (useGetUserProfiles as jest.Mock).mockReturnValue({ + loading: false, + userProfiles: mockUserProfiles, + }); (useSuggestUsers as jest.Mock).mockReturnValue({ loading: false, userProfiles: mockUserProfiles, From 912af23490d4d4ffc3f7352cf16f36bb48168300 Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Mon, 23 Oct 2023 15:10:24 +0200 Subject: [PATCH 05/53] [Security Solution][Detections] Add assignees UI into alert's details flyout component (#7662) (#169508) ## Summary Closes https://github.com/elastic/security-team/issues/7662 This PR adds Alert user assignment UI within alert's details flyout component. https://github.com/elastic/kibana/assets/2700761/b84299d7-5d65-4e9a-8836-807f51c0bbc7 This PR is a replacement to https://github.com/elastic/kibana/pull/168467 since I broke that one with wrong merges from main. cc @PhilippeOberti --- .../use_alert_assignees_actions.tsx | 5 +- .../take_action_dropdown/index.test.tsx | 12 +- .../components/take_action_dropdown/index.tsx | 17 +- .../right/components/assignees.test.tsx | 123 ++++++++ .../right/components/assignees.tsx | 161 ++++++++++ .../components/assignees_popover.test.tsx | 125 ++++++++ .../right/components/assignees_popover.tsx | 138 +++++++++ .../right/components/header_title.test.tsx | 7 +- .../right/components/header_title.tsx | 223 ++++++++------ .../right/components/test_ids.ts | 7 + .../flyout/document_details/right/header.tsx | 8 +- .../__snapshots__/index.test.tsx.snap | 112 ++++++- .../event_details/expandable_event.tsx | 31 +- .../event_details/flyout/footer.tsx | 286 ++++++++---------- .../event_details/flyout/header.tsx | 16 + .../side_panel/event_details/flyout/index.tsx | 11 +- .../flyout/use_refetch_by_scope.tsx | 45 +++ .../side_panel/event_details/index.test.tsx | 12 + .../side_panel/event_details/index.tsx | 13 +- .../components/side_panel/index.test.tsx | 12 + 20 files changed, 1096 insertions(+), 268 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees.test.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees_popover.test.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees_popover.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/use_refetch_by_scope.tsx 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 index 087c8bbd04981..ea7f9fa8314c6 100644 --- 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 @@ -18,12 +18,14 @@ export interface UseAlertAssigneesActionsProps { closePopover: () => void; ecsRowData: Ecs; refetch?: () => void; + refresh?: () => void; } export const useAlertAssigneesActions = ({ closePopover, ecsRowData, refetch, + refresh, }: UseAlertAssigneesActionsProps) => { const { hasIndexWrite } = useAlertsPrivileges(); const alertId = ecsRowData._id; @@ -68,10 +70,11 @@ export const useAlertAssigneesActions = ({ closePopoverMenu: closePopover, setIsBulkActionsLoading: () => {}, alertItems: alertAssigneeData, + refresh, }); return { title: panel.title, content, id: panel.id }; }), - [alertAssigneeData, alertAssigneesPanels, closePopover] + [alertAssigneeData, alertAssigneesPanels, closePopover, refresh] ); return { 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 d342092dd3d9d..2933cb857bd8b 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'); @@ -249,6 +252,13 @@ describe('take action dropdown', () => { ).toEqual(ALERT_TAGS_CONTEXT_MENU_ITEM_TITLE); }); }); + test('should render "Apply alert assignees"', 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..20486dbade0bf 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,13 @@ export const TakeActionDropdown = React.memo( refetch, }); + const { alertAssigneesItems, alertAssigneesPanels } = useAlertAssigneesActions({ + closePopover: closePopoverHandler, + ecsRowData: ecsData ?? { _id: actionsData.eventId }, + refresh: refetchFlyoutData, + refetch, + }); + const { investigateInTimelineActionItems } = useInvestigateInTimeline({ ecsRowData: ecsData, onInvestigateInTimelineAlertClick: closePopoverHandler, @@ -214,7 +222,12 @@ export const TakeActionDropdown = React.memo( const alertsActionItems = useMemo( () => !isEvent && actionsData.ruleId - ? [...statusActionItems, ...alertTagsItems, ...exceptionActionItems] + ? [ + ...statusActionItems, + ...alertTagsItems, + ...alertAssigneesItems, + ...exceptionActionItems, + ] : isEndpointEvent && canCreateEndpointEventFilters ? eventFilterActionItems : [], @@ -227,6 +240,7 @@ export const TakeActionDropdown = React.memo( isEvent, actionsData.ruleId, alertTagsItems, + alertAssigneesItems, ] ); @@ -271,6 +285,7 @@ export const TakeActionDropdown = React.memo( items, }, ...alertTagsPanels, + ...alertAssigneesPanels, ]; const takeActionButton = useMemo( 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..b1c86da72b058 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees.test.tsx @@ -0,0 +1,123 @@ +/* + * 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 type { UserProfileWithAvatar } from '@kbn/user-profile-components'; + +import { + ASSIGNEES_ADD_BUTTON_TEST_ID, + ASSIGNEES_COUNT_BADGE_TEST_ID, + ASSIGNEES_TITLE_TEST_ID, + ASSIGNEES_VALUE_TEST_ID, + ASSIGNEE_AVATAR_TEST_ID, +} from './test_ids'; +import { Assignees } from './assignees'; + +import { useGetUserProfiles } from '../../../../detections/containers/detection_engine/alerts/use_get_user_profiles'; +import { useSuggestUsers } from '../../../../detections/containers/detection_engine/alerts/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'; + +jest.mock('../../../../detections/containers/detection_engine/alerts/use_get_user_profiles'); +jest.mock('../../../../detections/containers/detection_engine/alerts/use_suggest_users'); +jest.mock('../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees'); + +const mockUserProfiles: UserProfileWithAvatar[] = [ + { 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() +) => + render( + + + + ); + +describe('', () => { + let setAlertAssigneesMock: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + (useGetUserProfiles as jest.Mock).mockReturnValue({ + loading: false, + userProfiles: mockUserProfiles, + }); + (useSuggestUsers as jest.Mock).mockReturnValue({ + loading: false, + userProfiles: mockUserProfiles, + }); + + 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(ASSIGNEES_VALUE_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID)).toBeInTheDocument(); + }); + + it('should render assignees avatars', () => { + const assignees = ['user-id-1', 'user-id-2']; + const { getByTestId, queryByTestId } = renderAssignees('test-event', assignees); + + expect(getByTestId(ASSIGNEE_AVATAR_TEST_ID('user1'))).toBeInTheDocument(); + expect(getByTestId(ASSIGNEE_AVATAR_TEST_ID('user2'))).toBeInTheDocument(); + + expect(queryByTestId(ASSIGNEES_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(ASSIGNEES_COUNT_BADGE_TEST_ID); + expect(assigneesCountBadge).toBeInTheDocument(); + expect(assigneesCountBadge).toHaveTextContent(`${assignees.length}`); + + expect(queryByTestId(ASSIGNEE_AVATAR_TEST_ID('user1'))).not.toBeInTheDocument(); + expect(queryByTestId(ASSIGNEE_AVATAR_TEST_ID('user2'))).not.toBeInTheDocument(); + expect(queryByTestId(ASSIGNEE_AVATAR_TEST_ID('user3'))).not.toBeInTheDocument(); + }); + + it('should call assignees update functionality with the right arguments', () => { + const assignees = ['user-id-1', 'user-id-2']; + const { getByTestId, getByText } = renderAssignees('test-event', assignees); + + // Update assignees + getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID).click(); + getByText('User 1').click(); + getByText('User 3').click(); + + // Close assignees popover + getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID).click(); + + expect(setAlertAssigneesMock).toHaveBeenCalledWith( + { + assignees_to_add: ['user-id-3'], + assignees_to_remove: ['user-id-1'], + }, + ['test-event'], + expect.anything(), + expect.anything() + ); + }); +}); 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..ea64acb8f9a0e --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees.tsx @@ -0,0 +1,161 @@ +/* + * 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, useEffect, useState } from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiNotificationBadge, + EuiTitle, + EuiToolTip, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { UserAvatar } from '@kbn/user-profile-components'; +import { noop } from 'lodash'; +import { useGetUserProfiles } from '../../../../detections/containers/detection_engine/alerts/use_get_user_profiles'; +import { useSetAlertAssignees } from '../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees'; +import { + ASSIGNEE_AVATAR_TEST_ID, + ASSIGNEES_TITLE_TEST_ID, + ASSIGNEES_VALUE_TEST_ID, + ASSIGNEES_COUNT_BADGE_TEST_ID, +} from './test_ids'; +import { AssigneesPopover } from './assignees_popover'; + +export interface AssigneesProps { + /** + * Id of the document + */ + eventId: string; + + /** + * The array of ids of the users assigned to the alert + */ + alertAssignees: 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, alertAssignees, onAssigneesUpdated }) => { + const { userProfiles } = useGetUserProfiles(alertAssignees); + const setAlertAssignees = useSetAlertAssignees(); + + const assignees = userProfiles?.filter((user) => alertAssignees.includes(user.uid)) ?? []; + + const [selectedAssignees, setSelectedAssignees] = useState(); + const [needToUpdateAssignees, setNeedToUpdateAssignees] = useState(false); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const onSuccess = useCallback(() => { + if (onAssigneesUpdated) onAssigneesUpdated(); + }, [onAssigneesUpdated]); + + const handleOnAlertAssigneesSubmit = useCallback(async () => { + if (setAlertAssignees && selectedAssignees) { + const existingIds = alertAssignees; + const updatedIds = selectedAssignees; + + const assigneesToAddArray = updatedIds.filter((uid) => !existingIds.includes(uid)); + const assigneesToRemoveArray = existingIds.filter((uid) => !updatedIds.includes(uid)); + + const assigneesToUpdate = { + assignees_to_add: assigneesToAddArray, + assignees_to_remove: assigneesToRemoveArray, + }; + + await setAlertAssignees(assigneesToUpdate, [eventId], onSuccess, noop); + } + }, [alertAssignees, eventId, onSuccess, selectedAssignees, setAlertAssignees]); + + const togglePopover = useCallback(() => { + setIsPopoverOpen((value) => !value); + setNeedToUpdateAssignees(true); + }, []); + + const onClosePopover = useCallback(() => { + // Order matters here because needToUpdateAssignees will likely be true already + // from the togglePopover call when opening the popover, so if we set the popover to false + // first, we'll get a rerender and then get another after we set needToUpdateAssignees to true again + setNeedToUpdateAssignees(true); + setIsPopoverOpen(false); + }, []); + + const onUsersChange = useCallback((users: string[]) => { + setSelectedAssignees(users); + }, []); + + useEffect(() => { + // selectedAssignees will be undefined on initial render or a rerender occurs, so we only want to update the assignees + // after the users have been changed in some manner not when it is an initial value + if (isPopoverOpen === false && needToUpdateAssignees && selectedAssignees) { + setNeedToUpdateAssignees(false); + handleOnAlertAssigneesSubmit(); + } + }, [handleOnAlertAssigneesSubmit, isPopoverOpen, needToUpdateAssignees, selectedAssignees]); + + return ( + + + +

+ +

+
+
+ + + {assignees.length > 2 ? ( + ( +
{user.user.email ?? user.user.username}
+ ))} + repositionOnScroll={true} + > + + {assignees.length} + +
+ ) : ( + assignees.map((user) => ( + + )) + )} +
+
+ + + +
+ ); + } +); + +Assignees.displayName = 'Assignees'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees_popover.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees_popover.test.tsx new file mode 100644 index 0000000000000..b9a5604657090 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees_popover.test.tsx @@ -0,0 +1,125 @@ +/* + * 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 type { UserProfileWithAvatar } from '@kbn/user-profile-components'; + +import { ASSIGNEES_ADD_BUTTON_TEST_ID } from './test_ids'; +import { AssigneesPopover } from './assignees_popover'; + +import { useSuggestUsers } from '../../../../detections/containers/detection_engine/alerts/use_suggest_users'; +import { TestProviders } from '../../../../common/mock'; + +jest.mock('../../../../detections/containers/detection_engine/alerts/use_suggest_users'); + +const mockUserProfiles: UserProfileWithAvatar[] = [ + { + 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 renderAssigneesPopover = ( + alertAssignees: string[], + isPopoverOpen: boolean, + onUsersChange = jest.fn(), + togglePopover = jest.fn(), + onClosePopover = jest.fn() +) => + render( + + + + ); + +describe('', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useSuggestUsers as jest.Mock).mockReturnValue({ + loading: false, + userProfiles: mockUserProfiles, + }); + }); + + it('should render closed popover component', () => { + const { getByTestId, queryByTestId } = renderAssigneesPopover([], false); + + expect(getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID)).toBeInTheDocument(); + expect(queryByTestId('euiSelectableList')).not.toBeInTheDocument(); + }); + + it('should render opened popover component', () => { + const { getByTestId } = renderAssigneesPopover([], true); + + expect(getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID)).toBeInTheDocument(); + expect(getByTestId('euiSelectableList')).toBeInTheDocument(); + }); + + it('should render assignees', () => { + const { getByTestId } = renderAssigneesPopover([], 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'); + }); + + it('should call onUsersChange on clsing the popover', () => { + const onUsersChangeMock = jest.fn(); + const { getByText } = renderAssigneesPopover([], true, onUsersChangeMock); + + 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']], + [[]], + ]); + }); + + it('should call togglePopover on add button click', () => { + const togglePopoverMock = jest.fn(); + const { getByTestId } = renderAssigneesPopover([], false, jest.fn(), togglePopoverMock); + + getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID).click(); + + expect(togglePopoverMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees_popover.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees_popover.tsx new file mode 100644 index 0000000000000..48a341714551f --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees_popover.tsx @@ -0,0 +1,138 @@ +/* + * 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, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; +import { UserProfilesPopover } from '@kbn/user-profile-components'; + +import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import { useSuggestUsers } from '../../../../detections/containers/detection_engine/alerts/use_suggest_users'; +import { ASSIGNEES_ADD_BUTTON_TEST_ID } from './test_ids'; + +const PopoverButton: FC<{ togglePopover: () => void; isDisabled: boolean }> = memo( + ({ togglePopover, isDisabled }) => ( + + + + ) +); +PopoverButton.displayName = 'PopoverButton'; + +export interface AssigneesPopoverProps { + /** + * Ids of the users assigned to the alert + */ + existingAssigneesIds: string[]; + + /** + * Boolean to allow popover to be opened or closed + */ + isPopoverOpen: boolean; + + /** + * Callback to handle changing ot the assignees selection + */ + onUsersChange: (users: string[]) => void; + + /** + * Callback to handle clicking the add assignees button to indicate that user wants to open/close the popover + */ + togglePopover: () => void; + + /** + * Callback to handle hiding of the popover + */ + onClosePopover: () => void; +} + +/** + * The popover to allow user assignees selection for the alert + */ +export const AssigneesPopover: FC = memo( + ({ existingAssigneesIds, isPopoverOpen, onUsersChange, togglePopover, onClosePopover }) => { + const [searchTerm, setSearchTerm] = useState(''); + const { loading: isLoadingUsers, userProfiles } = useSuggestUsers(searchTerm); + + const [selectedAssignees, setSelectedAssignees] = useState([]); + useEffect(() => { + if (isLoadingUsers) { + return; + } + const assignees = userProfiles.filter((user) => existingAssigneesIds.includes(user.uid)); + setSelectedAssignees(assignees); + }, [existingAssigneesIds, isLoadingUsers, userProfiles]); + + const handleSelectedAssignees = useCallback( + (newAssignees: UserProfileWithAvatar[]) => { + if (!isEqual(newAssignees, selectedAssignees)) { + setSelectedAssignees(newAssignees); + onUsersChange(newAssignees.map((user) => user.uid)); + } + }, + [onUsersChange, selectedAssignees] + ); + + const selectedStatusMessage = useCallback( + (total: number) => + i18n.translate( + 'xpack.securitySolution.flyout.right.visualizations.assignees.totalUsersAssigned', + { + defaultMessage: '{total, plural, one {# filter} other {# filters}} selected', + values: { total }, + } + ), + [] + ); + + return ( + } + isOpen={isPopoverOpen} + closePopover={onClosePopover} + panelStyle={{ + minWidth: 520, + }} + selectableProps={{ + onSearchChange: (term: string) => { + setSearchTerm(term); + }, + onChange: handleSelectedAssignees, + selectedStatusMessage, + options: userProfiles, + selectedOptions: selectedAssignees, + isLoading: isLoadingUsers, + height: 'full', + }} + /> + ); + } +); + +AssigneesPopover.displayName = 'AssigneesPopover'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/header_title.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/header_title.test.tsx index ca30009c7cbf7..16ffd74561580 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/header_title.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/header_title.test.tsx @@ -24,6 +24,7 @@ import { mockGetFieldsData } from '../../shared/mocks/mock_get_fields_data'; import { mockDataFormattedForFieldBrowser } from '../../shared/mocks/mock_data_formatted_for_field_browser'; import { useAssistant } from '../hooks/use_assistant'; import { TestProvidersComponent } from '../../../../common/mock'; +import { TimelineId } from '../../../../../common/types'; import { useGetAlertDetailsFlyoutLink } from '../../../../timelines/components/side_panel/event_details/use_get_alert_details_flyout_link'; import { FLYOUT_URL_PARAM } from '../../shared/hooks/url/use_sync_flyout_state_with_url'; @@ -55,7 +56,11 @@ const renderHeader = (contextValue: RightPanelContext) => - + 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 9f8373c2b3991..3b6dfb6f784dc 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,15 +6,17 @@ */ import type { VFC } from 'react'; -import React, { memo } from 'react'; +import React, { memo, useCallback, useMemo } from 'react'; import { NewChatById } from '@kbn/elastic-assistant'; import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui'; import { isEmpty } from 'lodash'; import { css } from '@emotion/react'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; +import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils'; import { FLYOUT_URL_PARAM } from '../../shared/hooks/url/use_sync_flyout_state_with_url'; import { CopyToClipboard } from '../../../shared/components/copy_to_clipboard'; +import { useRefetchByScope } from '../../../../timelines/components/side_panel/event_details/flyout/use_refetch_by_scope'; import { useGetAlertDetailsFlyoutLink } from '../../../../timelines/components/side_panel/event_details/use_get_alert_details_flyout_link'; import { DocumentStatus } from './status'; import { useAssistant } from '../hooks/use_assistant'; @@ -28,117 +30,148 @@ import { useBasicDataFromDetailsData } from '../../../../timelines/components/si import { useRightPanelContext } from '../context'; import { PreferenceFormattedDate } from '../../../../common/components/formatted_date'; import { FLYOUT_HEADER_TITLE_TEST_ID, SHARE_BUTTON_TEST_ID } from './test_ids'; +import { Assignees } from './assignees'; export interface HeaderTitleProps { /** * If false, update the margin-top to compensate the fact that the expand detail button is not displayed */ flyoutIsExpandable: boolean; + + /** + * Scope ID + */ + scopeId: string; + + /** + * Promise to trigger a data refresh + */ + refetchFlyoutData: () => Promise; } /** * Document details flyout right section header */ -export const HeaderTitle: VFC = memo(({ flyoutIsExpandable }) => { - const { dataFormattedForFieldBrowser, eventId, indexName } = useRightPanelContext(); - const { isAlert, ruleName, timestamp } = useBasicDataFromDetailsData( - dataFormattedForFieldBrowser - ); - const alertDetailsLink = useGetAlertDetailsFlyoutLink({ - _id: eventId, - _index: indexName, - timestamp, - }); +export const HeaderTitle: VFC = memo( + ({ flyoutIsExpandable, scopeId, refetchFlyoutData }) => { + const { dataFormattedForFieldBrowser, eventId, indexName, getFieldsData } = + useRightPanelContext(); + const { isAlert, ruleName, timestamp } = useBasicDataFromDetailsData( + dataFormattedForFieldBrowser + ); + const alertDetailsLink = useGetAlertDetailsFlyoutLink({ + _id: eventId, + _index: indexName, + timestamp, + }); - const showShareAlertButton = isAlert && alertDetailsLink; + const showShareAlertButton = isAlert && alertDetailsLink; - const { showAssistant, promptContextId } = useAssistant({ - dataFormattedForFieldBrowser, - isAlert, - }); + const { showAssistant, promptContextId } = useAssistant({ + dataFormattedForFieldBrowser, + isAlert, + }); - return ( - <> - {(showShareAlertButton || showAssistant) && ( - - {showAssistant && ( - - - - )} - {showShareAlertButton && ( - - { - const query = new URLSearchParams(window.location.search); - return `${value}&${FLYOUT_URL_PARAM}=${query.get(FLYOUT_URL_PARAM)}`; - }} - text={ - - } - iconType={'share'} - ariaLabel={i18n.translate( - 'xpack.securitySolution.flyout.right.header.shareButtonAriaLabel', - { - defaultMessage: 'Share Alert', + const { refetch } = useRefetchByScope({ scopeId }); + const alertAssignees = useMemo( + () => (getFieldsData(ALERT_WORKFLOW_ASSIGNEE_IDS) as string[]) ?? [], + [getFieldsData] + ); + const onAssigneesUpdated = useCallback(() => { + refetch(); + refetchFlyoutData(); + }, [refetch, refetchFlyoutData]); + + return ( + <> + {(showShareAlertButton || showAssistant) && ( + + {showAssistant && ( + + + + )} + {showShareAlertButton && ( + + { + const query = new URLSearchParams(window.location.search); + return `${value}&${FLYOUT_URL_PARAM}=${query.get(FLYOUT_URL_PARAM)}`; + }} + text={ + + } + iconType={'share'} + ariaLabel={i18n.translate( + 'xpack.securitySolution.flyout.right.header.shareButtonAriaLabel', + { + defaultMessage: 'Share Alert', + } + )} + data-test-subj={SHARE_BUTTON_TEST_ID} + /> + + )} + + )} + + +

+ {isAlert && !isEmpty(ruleName) ? ( + ruleName + ) : ( + - - )} + )} +

+
+ + + + + + + {timestamp && } + - )} - - -

- {isAlert && !isEmpty(ruleName) ? ( - ruleName - ) : ( - + + + + + + + + + - )} -

-
- - - - - - - {timestamp && } - - - - - - - - - - - - - ); -}); +
+
+ + ); + } +); HeaderTitle.displayName = 'HeaderTitle'; 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 21f92d76c96cb..220d4fb5b4cad 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 @@ -22,6 +22,9 @@ export const RISK_SCORE_TITLE_TEST_ID = `${FLYOUT_HEADER_TEST_ID}RiskScoreTitle` export const RISK_SCORE_VALUE_TEST_ID = `${FLYOUT_HEADER_TEST_ID}RiskScoreValue` as const; 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_TITLE_TEST_ID = `${FLYOUT_HEADER_TEST_ID}AssigneesTitle`; +export const ASSIGNEES_VALUE_TEST_ID = `${FLYOUT_HEADER_TEST_ID}AssigneesValue`; +export const ASSIGNEES_ADD_BUTTON_TEST_ID = `${FLYOUT_HEADER_TEST_ID}AssigneesAddButton`; /* About section */ @@ -147,3 +150,7 @@ export const RESPONSE_SECTION_HEADER_TEST_ID = RESPONSE_SECTION_TEST_ID + HEADER export const RESPONSE_SECTION_CONTENT_TEST_ID = RESPONSE_SECTION_TEST_ID + CONTENT_TEST_ID; export const RESPONSE_BUTTON_TEST_ID = `${RESPONSE_TEST_ID}Button` as const; export const RESPONSE_EMPTY_TEST_ID = `${RESPONSE_TEST_ID}Empty` as const; + +/* Alert Assignees */ +export const ASSIGNEE_AVATAR_TEST_ID = (userName: string) => `${PREFIX}AssigneeAvatar-${userName}`; +export const ASSIGNEES_COUNT_BADGE_TEST_ID = `${PREFIX}AssigneesCountBadge`; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/header.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/header.tsx index 80d809af07116..3f2912cb21720 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/header.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/header.tsx @@ -13,6 +13,7 @@ import type { RightPanelPaths } from '.'; import type { RightPanelTabsType } from './tabs'; import { HeaderTitle } from './components/header_title'; import { ExpandDetailButton } from './components/expand_detail_button'; +import { useRightPanelContext } from './context'; export interface PanelHeaderProps { /** @@ -36,6 +37,7 @@ export interface PanelHeaderProps { export const PanelHeader: VFC = memo( ({ flyoutIsExpandable, selectedTabId, setSelectedTabId, tabs }) => { + const { refetchFlyoutData, scopeId } = useRightPanelContext(); const onSelectedTabChanged = (id: RightPanelPaths) => setSelectedTabId(id); const renderTabs = tabs.map((tab, index) => ( = memo( )} - +
+
+
+
+

+ Assigned: +

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

+ Assigned: +

+
+
+ +
+
+
+
+ + + +
+
+
+
+
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 2f530424f9384..af912559be28e 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,12 @@ 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 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 { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { getAlertDetailsUrl } from '../../../../common/components/link_to'; @@ -41,6 +44,7 @@ import { import { PreferenceFormattedDate } from '../../../../common/components/formatted_date'; import { SecurityPageName } from '../../../../../common/constants'; import { useGetAlertDetailsFlyoutLink } from './use_get_alert_details_flyout_link'; +import { useRefetchByScope } from './flyout/use_refetch_by_scope'; export type HandleOnEventClosed = () => void; interface Props { @@ -68,6 +72,9 @@ interface ExpandableEventTitleProps { ruleName?: string; timestamp: string; handleOnEventClosed?: HandleOnEventClosed; + scopeId: string; + refetchFlyoutData: () => Promise; + getFieldsData: GetFieldsData; } const StyledEuiFlexGroup = styled(EuiFlexGroup)` @@ -96,6 +103,9 @@ export const ExpandableEventTitle = React.memo( promptContextId, ruleName, timestamp, + scopeId, + refetchFlyoutData, + getFieldsData, }) => { const { hasAssistantPrivilege } = useAssistantAvailability(); const isAlertDetailsPageEnabled = useIsExperimentalFeatureEnabled('alertDetailsPageEnabled'); @@ -110,6 +120,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 ( @@ -141,7 +161,7 @@ export const ExpandableEventTitle = React.memo( )} - + {handleOnEventClosed && ( ( )} + + + 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 ac5d5ee32797c..ee862fd4ca6b0 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,173 +41,139 @@ 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/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/index.tsx index d52fa2331e016..d853a00cf4c9d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/index.tsx @@ -9,6 +9,7 @@ import { EntityType } from '@kbn/timelines-plugin/common'; import { noop } from 'lodash/fp'; import React, { useCallback, useMemo, useState } from 'react'; +import { useGetFieldsData } from '../../../../../common/hooks/use_get_fields_data'; import { TimelineId } from '../../../../../../common/types'; import { useHostIsolationTools } from '../use_host_isolation_tools'; import { FlyoutHeaderContent } from './header'; @@ -39,6 +40,7 @@ export const useToGetInternalFlyout = () => { skip: !alert.id, } ); + const getFieldsData = useGetFieldsData(rawEventData?.fields); const { alertId, isAlert, hostName, ruleName, timestamp } = useBasicDataFromDetailsData(detailsData); @@ -114,19 +116,24 @@ export const useToGetInternalFlyout = () => { ruleName={ruleName} showAlertDetails={showAlertDetails} timestamp={timestamp} + scopeId={TimelineId.casePage} + refetchFlyoutData={refetchFlyoutData} + getFieldsData={getFieldsData} /> ); }, [ - alert.indexName, + isHostIsolationPanelOpen, isAlert, + alert.indexName, alertId, - isHostIsolationPanelOpen, isolateAction, loading, ruleName, showAlertDetails, timestamp, + refetchFlyoutData, + 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 10a536f69c8d0..6c2da93bcddfc 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 @@ -73,6 +73,18 @@ jest.mock( } ); +jest.mock('../../../../detections/containers/detection_engine/alerts/use_get_user_profiles', () => { + return { + useGetUserProfiles: jest.fn().mockReturnValue({ loading: false, userProfiles: [] }), + }; +}); + +jest.mock('../../../../detections/containers/detection_engine/alerts/use_suggest_users', () => { + return { + useSuggestUsers: jest.fn().mockReturnValue({ loading: false, userProfiles: [] }), + }; +}); + jest.mock('../../../../common/hooks/use_experimental_features', () => ({ useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true), })); 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 729b20e68cb15..a3eac26d5d3b1 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 @@ -12,6 +12,7 @@ import React, { useCallback, useMemo } from 'react'; 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'; @@ -87,6 +88,7 @@ const EventDetailsPanelComponent: React.FC = ({ skip: !expandedEvent.eventId, } ); + const getFieldsData = useGetFieldsData(rawEventData?.fields); const { isolateAction, @@ -130,6 +132,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} /> ), [ @@ -154,8 +162,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..5e7cc86ecc3ec 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('../../../detections/containers/detection_engine/alerts/use_get_user_profiles', () => { + return { + useGetUserProfiles: jest.fn().mockReturnValue({ loading: false, userProfiles: [] }), + }; +}); + +jest.mock('../../../detections/containers/detection_engine/alerts/use_suggest_users', () => { + return { + useSuggestUsers: jest.fn().mockReturnValue({ loading: false, userProfiles: [] }), + }; +}); + jest.mock('../../../assistant/use_assistant_availability'); const mockUseLocation = jest.fn().mockReturnValue({ pathname: '/test', search: '?' }); jest.mock('react-router-dom', () => { From 43d191e0f2fb59d298da9b8f587f8b886df27836 Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Mon, 23 Oct 2023 15:12:45 +0200 Subject: [PATCH 06/53] Locked Status and Assignee Controls for Alert Page (#7820) (#169235) ## Summary Closes https://github.com/elastic/security-team/issues/7820 This PR adds "filter by assignees" component to the Alerts page. https://github.com/elastic/kibana/assets/2700761/3f2c27bd-503f-4f0d-86ce-4e1f949db182 --- .../components/filter_group/constants.ts | 1 + .../filter_group/filter_by_assignees.test.tsx | 110 ++++++++++++++++ .../filter_group/filter_by_assignees.tsx | 117 ++++++++++++++++++ .../alert_bulk_assignees.test.tsx | 3 +- .../use_bulk_alert_assignees_items.test.tsx | 3 +- .../alerts_table/default_config.test.tsx | 42 +++++++ .../alerts_table/default_config.tsx | 27 ++++ .../use_alert_assignees_actions.test.tsx | 3 +- .../detection_page_filters/index.test.tsx | 29 +++++ .../detection_page_filters/index.tsx | 43 +++++-- .../detection_engine/detection_engine.tsx | 22 +++- 11 files changed, 383 insertions(+), 17 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/components/filter_group/filter_by_assignees.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/filter_group/filter_by_assignees.tsx 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..a8124767f11c6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/filter_group/filter_by_assignees.test.tsx @@ -0,0 +1,110 @@ +/* + * 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'; + +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('../../../detections/containers/detection_engine/alerts/use_suggest_users', () => { + return { + useSuggestUsers: () => ({ + loading: false, + userProfiles: mockUserProfiles, + }), + }; +}); + +const renderFilterByAssigneesPopover = (alertAssignees?: string[], onUsersChange = jest.fn()) => + render( + + + + ); + +describe('', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + 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 clsing 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..8fb691168dab9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/filter_group/filter_by_assignees.tsx @@ -0,0 +1,117 @@ +/* + * 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, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; +import { UserProfilesPopover } from '@kbn/user-profile-components'; + +import { EuiFilterButton } from '@elastic/eui'; +import { useSuggestUsers } from '../../../detections/containers/detection_engine/alerts/use_suggest_users'; +import { TEST_IDS } from './constants'; + +export interface FilterByAssigneesPopoverProps { + /** + * Ids of the users assigned to the alert + */ + existingAssigneesIds?: string[]; + + /** + * Callback to handle changing of the assignees selection + */ + onUsersChange?: (users: string[]) => void; +} + +/** + * The popover to filter alerts by assigned users + */ +export const FilterByAssigneesPopover: FC = memo( + ({ existingAssigneesIds, onUsersChange }) => { + const [searchTerm, setSearchTerm] = useState(''); + const { loading: isLoadingUsers, userProfiles } = useSuggestUsers(searchTerm); + + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const togglePopover = useCallback(() => setIsPopoverOpen((value) => !value), []); + + const [selectedAssignees, setSelectedAssignees] = useState([]); + useEffect(() => { + if (isLoadingUsers) { + return; + } + const assignees = userProfiles.filter((user) => existingAssigneesIds?.includes(user.uid)); + setSelectedAssignees(assignees); + }, [existingAssigneesIds, isLoadingUsers, userProfiles]); + + const handleSelectedAssignees = useCallback( + (newAssignees: UserProfileWithAvatar[]) => { + if (!isEqual(newAssignees, selectedAssignees)) { + setSelectedAssignees(newAssignees); + onUsersChange?.(newAssignees.map((user) => user.uid)); + } + }, + [onUsersChange, selectedAssignees] + ); + + const selectedStatusMessage = useCallback( + (total: number) => + i18n.translate( + 'xpack.securitySolution.flyout.right.visualizations.assignees.totalUsersAssigned', + { + defaultMessage: '{total, plural, one {# filter} other {# filters}} selected', + values: { total }, + } + ), + [] + ); + + return ( + 0} + numActiveFilters={selectedAssignees.length} + > + {i18n.translate('xpack.securitySolution.filtersGroup.assignees.buttonTitle', { + defaultMessage: 'Assignees', + })} + + } + isOpen={isPopoverOpen} + closePopover={togglePopover} + panelStyle={{ + minWidth: 520, + }} + selectableProps={{ + onSearchChange: (term: string) => { + setSearchTerm(term); + }, + onChange: handleSelectedAssignees, + selectedStatusMessage, + options: userProfiles, + selectedOptions: selectedAssignees, + isLoading: isLoadingUsers, + height: 'full', + }} + /> + ); + } +); + +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 index 2d7748cf76691..8e0f9e1ecfada 100644 --- 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 @@ -6,7 +6,6 @@ */ import type { TimelineItem } from '@kbn/timelines-plugin/common'; -import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; import { act, fireEvent, render } from '@testing-library/react'; import React from 'react'; import { TestProviders } from '../../../mock'; @@ -19,7 +18,7 @@ import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils'; jest.mock('../../../../detections/containers/detection_engine/alerts/use_get_user_profiles'); jest.mock('../../../../detections/containers/detection_engine/alerts/use_suggest_users'); -const mockUserProfiles: UserProfileWithAvatar[] = [ +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/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 index f04ae2aef3d2b..9b72b20747391 100644 --- 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 @@ -7,7 +7,6 @@ import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils'; import { TestProviders } from '@kbn/timelines-plugin/public/mock'; -import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; import { act, fireEvent, render } from '@testing-library/react'; import { renderHook } from '@testing-library/react-hooks'; import type { @@ -23,7 +22,7 @@ jest.mock('./use_set_alert_assignees'); jest.mock('../../../../detections/containers/detection_engine/alerts/use_get_user_profiles'); jest.mock('../../../../detections/containers/detection_engine/alerts/use_suggest_users'); -const mockUserProfiles: UserProfileWithAvatar[] = [ +const mockUserProfiles = [ { uid: 'user-id-1', enabled: true, user: { username: 'fakeUser1' }, data: {} }, { uid: 'user-id-2', enabled: true, user: { username: 'fakeUser2' }, data: {} }, ]; 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 10204204e9fc3..4288231eae7d7 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,6 +9,7 @@ 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'; @@ -152,6 +153,32 @@ export const buildThreatMatchFilter = (showOnlyThreatIndicatorAlerts: boolean): ] : []; +export const buildAlertAssigneesFilter = (assigneesIds: string[]): Filter[] => { + if (!assigneesIds.length) { + return []; + } + const combinedQuery = { + bool: { + should: assigneesIds.map((id) => ({ + term: { + [ALERT_WORKFLOW_ASSIGNEE_IDS]: id, + }, + })), + }, + }; + + return [ + { + meta: { + alias: null, + negate: false, + disabled: false, + }, + query: combinedQuery, + }, + ]; +}; + export const getAlertsDefaultModel = (license?: LicenseService): SubsetDataTableModel => ({ ...tableDefaults, columns: getColumns(license), 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 index a11441ed0f346..0dc38dbc153de 100644 --- 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 @@ -6,7 +6,6 @@ */ import { TestProviders } from '@kbn/timelines-plugin/public/mock'; -import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; import { renderHook } from '@testing-library/react-hooks'; import type { UseAlertAssigneesActionsProps } from './use_alert_assignees_actions'; import { useAlertAssigneesActions } from './use_alert_assignees_actions'; @@ -25,7 +24,7 @@ jest.mock('../../../../common/components/toolbar/bulk_actions/use_set_alert_assi jest.mock('../../../containers/detection_engine/alerts/use_get_user_profiles'); jest.mock('../../../containers/detection_engine/alerts/use_suggest_users'); -const mockUserProfiles: UserProfileWithAvatar[] = [ +const mockUserProfiles = [ { uid: 'user-id-1', enabled: true, user: { username: 'fakeUser1' }, data: {} }, { uid: 'user-id-2', enabled: true, user: { username: 'fakeUser2' }, data: {} }, ]; 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..3c50ab7ef51d3 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('../../containers/detection_engine/alerts/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/detection_page_filters/index.tsx b/x-pack/plugins/security_solution/public/detections/components/detection_page_filters/index.tsx index 6e149b9866357..c05ecd84e5d98 100644 --- a/x-pack/plugins/security_solution/public/detections/components/detection_page_filters/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/detection_page_filters/index.tsx @@ -9,7 +9,9 @@ import type { ComponentProps } from 'react'; import React, { useEffect, useState, useCallback } from 'react'; import type { Filter } from '@kbn/es-query'; import { isEqual } from 'lodash'; -import { EuiFlexItem } from '@elastic/eui'; +import { EuiFilterGroup, EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { FilterByAssigneesPopover } from '../../../common/components/filter_group/filter_by_assignees'; import { SourcererScopeName } from '../../../common/store/sourcerer/model'; import { FilterGroupLoading } from '../../../common/components/filter_group/loading'; import { useKibana } from '../../../common/lib/kibana'; @@ -20,7 +22,10 @@ import { useSourcererDataView } from '../../../common/containers/sourcerer'; type FilterItemSetProps = Omit< ComponentProps, 'initialControls' | 'dataViewId' ->; +> & { + assignees?: string[]; + onAssigneesChange?: (users: string[]) => void; +}; const SECURITY_ALERT_DATA_VIEW = { id: 'security_solution_alerts_dv', @@ -28,8 +33,9 @@ const SECURITY_ALERT_DATA_VIEW = { }; const FilterItemSetComponent = (props: FilterItemSetProps) => { - const { onFilterChange, ...restFilterItemGroupProps } = props; + const { assignees, onAssigneesChange, onFilterChange, ...restFilterItemGroupProps } = props; + const { euiTheme } = useEuiTheme(); const { indexPattern: { title }, dataViewId, @@ -89,12 +95,31 @@ const FilterItemSetComponent = (props: FilterItemSetProps) => { } return ( - + + + + + + + + + + + + ); }; 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 5291c5326ae3f..4a10917b71422 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,7 @@ import { tableDefaults, TableId, } from '@kbn/securitysolution-data-table'; +import { isEqual } from 'lodash'; 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 +63,7 @@ import { showGlobalFilters, } from '../../../timelines/components/timeline/helpers'; import { + buildAlertAssigneesFilter, buildAlertStatusFilter, buildShowBuildingBlockFilter, buildThreatMatchFilter, @@ -135,6 +137,16 @@ const DetectionEnginePageComponent: React.FC = ({ const { loading: listsConfigLoading, needsConfiguration: needsListsConfiguration } = useListsConfig(); + const [assignees, setAssignees] = useState([]); + const handleSelectedAssignees = useCallback( + (newAssignees: string[]) => { + if (!isEqual(newAssignees, assignees)) { + setAssignees(newAssignees); + } + }, + [assignees] + ); + const arePageFiltersEnabled = useIsExperimentalFeatureEnabled('alertsPageFiltersEnabled'); // when arePageFiltersEnabled === false @@ -176,8 +188,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 +260,9 @@ const DetectionEnginePageComponent: React.FC = ({ ...buildShowBuildingBlockFilter(showBuildingBlockAlerts), ...buildThreatMatchFilter(showOnlyThreatIndicatorAlerts), ...(alertPageFilters ?? []), + ...buildAlertAssigneesFilter(assignees), ], - [showBuildingBlockAlerts, showOnlyThreatIndicatorAlerts, alertPageFilters] + [assignees, showBuildingBlockAlerts, showOnlyThreatIndicatorAlerts, alertPageFilters] ); const { signalIndexNeedsInit, pollForSignalIndex } = useSignalHelpers(); @@ -360,6 +374,8 @@ const DetectionEnginePageComponent: React.FC = ({ }} chainingSystem={'HIERARCHICAL'} onInit={setDetectionPageFilterHandler} + assignees={assignees} + onAssigneesChange={handleSelectedAssignees} /> ), [ @@ -374,6 +390,8 @@ const DetectionEnginePageComponent: React.FC = ({ timelinesUi, to, updatedAt, + assignees, + handleSelectedAssignees, ] ); From 5d1a919e170b58a1e67dbe124cc064e7552d7935 Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Mon, 23 Oct 2023 16:13:26 +0200 Subject: [PATCH 07/53] [Security Solution][Detections] UI and tests fixes of alert user assignments (#169534) ## Summary A few fixes: 1. Broken test fix as a followup to https://github.com/elastic/kibana/pull/169235 2. Make user profiles popover of a fixed size of 414px --- .../toolbar/bulk_actions/alert_bulk_assignees.test.tsx | 2 +- .../timeline_actions/use_alert_assignees_actions.tsx | 2 +- .../document_details/right/components/assignees_popover.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 index 8e0f9e1ecfada..c82c700cc7b7a 100644 --- 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 @@ -23,7 +23,7 @@ const mockUserProfiles = [ { uid: 'user-id-2', enabled: true, user: { username: 'user2' }, data: {} }, ]; -const mockSuggestedUserProfiles: UserProfileWithAvatar[] = [ +const mockSuggestedUserProfiles = [ ...mockUserProfiles, { uid: 'user-id-3', enabled: true, user: { username: 'user3' }, data: {} }, { uid: 'user-id-4', enabled: true, user: { username: 'user4' }, data: {} }, 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 index ea7f9fa8314c6..7353354bb8adb 100644 --- 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 @@ -72,7 +72,7 @@ export const useAlertAssigneesActions = ({ alertItems: alertAssigneeData, refresh, }); - return { title: panel.title, content, id: panel.id }; + return { title: panel.title, content, id: panel.id, width: 414 }; }), [alertAssigneeData, alertAssigneesPanels, closePopover, refresh] ); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees_popover.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees_popover.tsx index 48a341714551f..59bd662f76e82 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees_popover.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees_popover.tsx @@ -117,7 +117,7 @@ export const AssigneesPopover: FC = memo( isOpen={isPopoverOpen} closePopover={onClosePopover} panelStyle={{ - minWidth: 520, + minWidth: 414, }} selectableProps={{ onSearchChange: (term: string) => { From 4fc25ba8f2381441b966e1b9d0bfab84a1f58fac Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Wed, 25 Oct 2023 09:59:18 +0200 Subject: [PATCH 08/53] [Security Solution] Move user profiles related hooks into a separate folder (#169645) ## Summary These changes move user profiles hooks into a separate folder. Before it was part of the `containers/detection_engine/alerts/`. --- .../filter_group/filter_by_assignees.test.tsx | 2 +- .../filter_group/filter_by_assignees.tsx | 2 +- .../alert_bulk_assignees.test.tsx | 8 ++-- .../bulk_actions/alert_bulk_assignees.tsx | 4 +- .../use_bulk_alert_assignees_items.test.tsx | 8 ++-- .../use_alert_assignees_actions.test.tsx | 8 ++-- .../detection_page_filters/index.test.tsx | 2 +- .../render_cell_value.tsx | 2 +- .../detection_engine/alerts/__mocks__/api.ts | 15 +------ .../detection_engine/alerts/api.test.ts | 26 ----------- .../detection_engine/alerts/mock.ts | 6 --- .../user_profiles/__mocks__/api.ts | 15 +++++++ .../user_profiles/api.test.ts | 45 +++++++++++++++++++ .../detection_engine/user_profiles/api.ts | 28 ++++++++++++ .../detection_engine/user_profiles/mock.ts | 11 +++++ .../user_profiles/translations.ts | 13 ++++++ .../detection_engine/user_profiles/types.ts | 10 +++++ .../use_get_user_profiles.test.tsx | 5 ++- .../use_get_user_profiles.tsx | 3 +- .../use_suggest_users.test.tsx | 3 +- .../use_suggest_users.tsx | 3 +- .../right/components/assignees.test.tsx | 8 ++-- .../right/components/assignees.tsx | 2 +- .../components/assignees_popover.test.tsx | 4 +- .../right/components/assignees_popover.tsx | 2 +- .../side_panel/event_details/index.test.tsx | 26 ++++++----- .../components/side_panel/index.test.tsx | 17 ++++--- 27 files changed, 184 insertions(+), 94 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/__mocks__/api.ts create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/api.test.ts create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/api.ts create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/mock.ts create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/translations.ts create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/types.ts rename x-pack/plugins/security_solution/public/detections/containers/detection_engine/{alerts => user_profiles}/use_get_user_profiles.test.tsx (99%) rename x-pack/plugins/security_solution/public/detections/containers/detection_engine/{alerts => user_profiles}/use_get_user_profiles.tsx (99%) rename x-pack/plugins/security_solution/public/detections/containers/detection_engine/{alerts => user_profiles}/use_suggest_users.test.tsx (99%) rename x-pack/plugins/security_solution/public/detections/containers/detection_engine/{alerts => user_profiles}/use_suggest_users.tsx (99%) 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 index a8124767f11c6..acf03eb29e116 100644 --- 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 @@ -32,7 +32,7 @@ const mockUserProfiles = [ data: {}, }, ]; -jest.mock('../../../detections/containers/detection_engine/alerts/use_suggest_users', () => { +jest.mock('../../../detections/containers/detection_engine/user_profiles/use_suggest_users', () => { return { useSuggestUsers: () => ({ loading: false, 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 index 8fb691168dab9..0908a9fd21abd 100644 --- 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 @@ -13,7 +13,7 @@ import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; import { UserProfilesPopover } from '@kbn/user-profile-components'; import { EuiFilterButton } from '@elastic/eui'; -import { useSuggestUsers } from '../../../detections/containers/detection_engine/alerts/use_suggest_users'; +import { useSuggestUsers } from '../../../detections/containers/detection_engine/user_profiles/use_suggest_users'; import { TEST_IDS } from './constants'; export interface FilterByAssigneesPopoverProps { 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 index c82c700cc7b7a..f358885143b72 100644 --- 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 @@ -9,14 +9,14 @@ 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 { useGetUserProfiles } from '../../../../detections/containers/detection_engine/alerts/use_get_user_profiles'; -import { useSuggestUsers } from '../../../../detections/containers/detection_engine/alerts/use_suggest_users'; +import { useGetUserProfiles } from '../../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'; +import { useSuggestUsers } from '../../../../detections/containers/detection_engine/user_profiles/use_suggest_users'; import { BulkAlertAssigneesPanel } from './alert_bulk_assignees'; import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils'; -jest.mock('../../../../detections/containers/detection_engine/alerts/use_get_user_profiles'); -jest.mock('../../../../detections/containers/detection_engine/alerts/use_suggest_users'); +jest.mock('../../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'); +jest.mock('../../../../detections/containers/detection_engine/user_profiles/use_suggest_users'); const mockUserProfiles = [ { uid: 'user-id-1', enabled: true, user: { username: 'user1' }, data: {} }, 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 index 9565054eab874..378aab17b19d7 100644 --- 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 @@ -13,8 +13,8 @@ import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils'; import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; import { UserProfilesSelectable } from '@kbn/user-profile-components'; -import { useGetUserProfiles } from '../../../../detections/containers/detection_engine/alerts/use_get_user_profiles'; -import { useSuggestUsers } from '../../../../detections/containers/detection_engine/alerts/use_suggest_users'; +import { useGetUserProfiles } from '../../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'; +import { useSuggestUsers } from '../../../../detections/containers/detection_engine/user_profiles/use_suggest_users'; import * as i18n from './translations'; import type { SetAlertAssigneesFunc } from './use_set_alert_assignees'; 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 index 9b72b20747391..732e42c32c1a7 100644 --- 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 @@ -15,12 +15,12 @@ import type { } from './use_bulk_alert_assignees_items'; import { useBulkAlertAssigneesItems } from './use_bulk_alert_assignees_items'; import { useSetAlertAssignees } from './use_set_alert_assignees'; -import { useGetUserProfiles } from '../../../../detections/containers/detection_engine/alerts/use_get_user_profiles'; -import { useSuggestUsers } from '../../../../detections/containers/detection_engine/alerts/use_suggest_users'; +import { useGetUserProfiles } from '../../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'; +import { useSuggestUsers } from '../../../../detections/containers/detection_engine/user_profiles/use_suggest_users'; jest.mock('./use_set_alert_assignees'); -jest.mock('../../../../detections/containers/detection_engine/alerts/use_get_user_profiles'); -jest.mock('../../../../detections/containers/detection_engine/alerts/use_suggest_users'); +jest.mock('../../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'); +jest.mock('../../../../detections/containers/detection_engine/user_profiles/use_suggest_users'); const mockUserProfiles = [ { uid: 'user-id-1', enabled: true, user: { username: 'fakeUser1' }, data: {} }, 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 index 0dc38dbc153de..113cbb802e07c 100644 --- 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 @@ -16,13 +16,13 @@ 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 { useGetUserProfiles } from '../../../containers/detection_engine/alerts/use_get_user_profiles'; -import { useSuggestUsers } from '../../../containers/detection_engine/alerts/use_suggest_users'; +import { useGetUserProfiles } from '../../../containers/detection_engine/user_profiles/use_get_user_profiles'; +import { useSuggestUsers } from '../../../containers/detection_engine/user_profiles/use_suggest_users'; jest.mock('../../../containers/detection_engine/alerts/use_alerts_privileges'); jest.mock('../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees'); -jest.mock('../../../containers/detection_engine/alerts/use_get_user_profiles'); -jest.mock('../../../containers/detection_engine/alerts/use_suggest_users'); +jest.mock('../../../containers/detection_engine/user_profiles/use_get_user_profiles'); +jest.mock('../../../containers/detection_engine/user_profiles/use_suggest_users'); const mockUserProfiles = [ { uid: 'user-id-1', enabled: true, user: { username: 'fakeUser1' }, data: {} }, 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 3c50ab7ef51d3..0def4b173450c 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 @@ -43,7 +43,7 @@ const mockUserProfiles = [ data: {}, }, ]; -jest.mock('../../containers/detection_engine/alerts/use_suggest_users', () => { +jest.mock('../../containers/detection_engine/user_profiles/use_suggest_users', () => { return { useSuggestUsers: () => ({ loading: false, 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 ae4e2428f5e86..d9db2d1c80ad1 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 @@ -44,7 +44,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 { useGetUserProfiles } from '../../containers/detection_engine/alerts/use_get_user_profiles'; +import { useGetUserProfiles } from '../../containers/detection_engine/user_profiles/use_get_user_profiles'; /** * This implementation of `EuiDataGrid`'s `renderCellValue` diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/__mocks__/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/__mocks__/api.ts index 80eef1e998956..d2eda8a8762e1 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/__mocks__/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/__mocks__/api.ts @@ -5,7 +5,6 @@ * 2.0. */ -import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; import type { QueryAlerts, AlertSearchResponse, @@ -14,13 +13,7 @@ import type { Privilege, CasesFromAlertsResponse, } from '../types'; -import { - alertsMock, - mockSignalIndex, - mockUserPrivilege, - mockCaseIdsFromAlertId, - mockUserProfiles, -} from '../mock'; +import { alertsMock, mockSignalIndex, mockUserPrivilege, mockCaseIdsFromAlertId } from '../mock'; export const fetchQueryAlerts = async ({ query, @@ -43,9 +36,3 @@ export const getCaseIdsFromAlertId = async ({ }: { alertId: string; }): Promise => Promise.resolve(mockCaseIdsFromAlertId); - -export const suggestUsers = async ({ - searchTerm, -}: { - searchTerm: string; -}): Promise => Promise.resolve(mockUserProfiles); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.test.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.test.ts index 13c2cd1bafeed..92801daeba514 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.test.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.test.ts @@ -13,7 +13,6 @@ import { mockSignalIndex, mockUserPrivilege, mockHostIsolation, - mockUserProfiles, } from './mock'; import { fetchQueryAlerts, @@ -23,7 +22,6 @@ import { createHostIsolation, updateAlertStatusByQuery, updateAlertStatusByIds, - suggestUsers, } from './api'; import { coreMock } from '@kbn/core/public/mocks'; @@ -266,28 +264,4 @@ describe('Detections Alerts API', () => { expect(hostIsolationResponse).toEqual(mockHostIsolation); }); }); - - describe('suggestUsers', () => { - beforeEach(() => { - fetchMock.mockClear(); - fetchMock.mockResolvedValue(mockUserProfiles); - }); - - test('check parameter url', async () => { - await suggestUsers({ searchTerm: 'name1' }); - expect(fetchMock).toHaveBeenCalledWith( - '/api/detection_engine/signals/suggest_users', - 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/detections/containers/detection_engine/alerts/mock.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/mock.ts index 94d6112c3adbe..8782f9ecad631 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/mock.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/mock.ts @@ -5,7 +5,6 @@ * 2.0. */ -import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; import type { HostIsolationResponse } from '../../../../../common/endpoint/types/actions'; import type { AlertSearchResponse, AlertsIndex, Privilege, CasesFromAlertsResponse } from './types'; @@ -1335,8 +1334,3 @@ export const mockCaseIdsFromAlertId: CasesFromAlertsResponse = [ { id: '818601a0-b26b-11eb-8759-6b318e8cf4bc', title: 'Case 1' }, { id: '8a774850-b26b-11eb-8759-6b318e8cf4bc', title: 'Case 2' }, ]; - -export const mockUserProfiles: UserProfileWithAvatar[] = [ - { 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/detections/containers/detection_engine/user_profiles/__mocks__/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/__mocks__/api.ts new file mode 100644 index 0000000000000..34a24bb4e00f7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/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/detections/containers/detection_engine/user_profiles/api.test.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/api.test.ts new file mode 100644 index 0000000000000..534bd398cf304 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/api.test.ts @@ -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 { coreMock } from '@kbn/core/public/mocks'; + +import { mockUserProfiles } from './mock'; +import { suggestUsers } from './api'; +import { KibanaServices } from '../../../../common/lib/kibana'; + +const mockKibanaServices = KibanaServices.get as jest.Mock; +jest.mock('../../../../common/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( + '/api/detection_engine/signals/suggest_users', + 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/detections/containers/detection_engine/user_profiles/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/api.ts new file mode 100644 index 0000000000000..920c050a044c3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/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 '../../../../common/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/detections/containers/detection_engine/user_profiles/mock.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/mock.ts new file mode 100644 index 0000000000000..930bd4d078433 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/mock.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. + */ + +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/detections/containers/detection_engine/user_profiles/translations.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/translations.ts new file mode 100644 index 0000000000000..4058d40a927f2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/translations.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. + */ + +import { i18n } from '@kbn/i18n'; + +export const USER_PROFILES_FAILURE = i18n.translate( + 'xpack.securitySolution.containers.detectionEngine.userProfiles.title', + { defaultMessage: 'Failed to find users' } +); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/types.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/types.ts new file mode 100644 index 0000000000000..2d0586dd571a3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/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/detections/containers/detection_engine/alerts/use_get_user_profiles.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_get_user_profiles.test.tsx similarity index 99% rename from x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_get_user_profiles.test.tsx rename to x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_get_user_profiles.test.tsx index 709dd27e1b82b..2f88d2e80c714 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_get_user_profiles.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_get_user_profiles.test.tsx @@ -6,13 +6,14 @@ */ import { renderHook } from '@testing-library/react-hooks'; +import { securityMock } from '@kbn/security-plugin/public/mocks'; + +import { mockUserProfiles } from './mock'; import { useGetUserProfiles } from './use_get_user_profiles'; import { useKibana } from '../../../../common/lib/kibana'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import { useAppToastsMock } from '../../../../common/hooks/use_app_toasts.mock'; -import { mockUserProfiles } from './mock'; import { createStartServicesMock } from '../../../../common/lib/kibana/kibana_react.mock'; -import { securityMock } from '@kbn/security-plugin/public/mocks'; jest.mock('../../../../common/lib/kibana'); jest.mock('../../../../common/hooks/use_app_toasts'); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_get_user_profiles.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_get_user_profiles.tsx similarity index 99% rename from x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_get_user_profiles.tsx rename to x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_get_user_profiles.tsx index bea9301bdbdd1..3172d3e6d7062 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_get_user_profiles.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_get_user_profiles.tsx @@ -7,9 +7,10 @@ import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; import { useEffect, useState } from 'react'; + +import { USER_PROFILES_FAILURE } from './translations'; import { useKibana } from '../../../../common/lib/kibana'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; -import { USER_PROFILES_FAILURE } from './translations'; interface GetUserProfilesReturn { loading: boolean; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_suggest_users.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_suggest_users.test.tsx similarity index 99% rename from x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_suggest_users.test.tsx rename to x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_suggest_users.test.tsx index f22e0cdd8f59a..d9af3eb8e56c7 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_suggest_users.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_suggest_users.test.tsx @@ -7,10 +7,11 @@ 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 '../../../../common/hooks/use_app_toasts'; import { useAppToastsMock } from '../../../../common/hooks/use_app_toasts.mock'; -import { mockUserProfiles } from './mock'; jest.mock('./api'); jest.mock('../../../../common/hooks/use_app_toasts'); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_suggest_users.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_suggest_users.tsx similarity index 99% rename from x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_suggest_users.tsx rename to x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_suggest_users.tsx index 3d44d1ef5596e..7d678bc8438e9 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_suggest_users.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_suggest_users.tsx @@ -7,9 +7,10 @@ import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; import { useEffect, useState } from 'react'; -import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; + import { suggestUsers } from './api'; import { USER_PROFILES_FAILURE } from './translations'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; interface SuggestUsersReturn { loading: boolean; 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 index b1c86da72b058..778d30ccb10a7 100644 --- 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 @@ -18,14 +18,14 @@ import { } from './test_ids'; import { Assignees } from './assignees'; -import { useGetUserProfiles } from '../../../../detections/containers/detection_engine/alerts/use_get_user_profiles'; -import { useSuggestUsers } from '../../../../detections/containers/detection_engine/alerts/use_suggest_users'; +import { useGetUserProfiles } from '../../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'; +import { useSuggestUsers } from '../../../../detections/containers/detection_engine/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'; -jest.mock('../../../../detections/containers/detection_engine/alerts/use_get_user_profiles'); -jest.mock('../../../../detections/containers/detection_engine/alerts/use_suggest_users'); +jest.mock('../../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'); +jest.mock('../../../../detections/containers/detection_engine/user_profiles/use_suggest_users'); jest.mock('../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees'); const mockUserProfiles: UserProfileWithAvatar[] = [ 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 index ea64acb8f9a0e..5f4d05a8f4a98 100644 --- 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 @@ -17,7 +17,7 @@ import { import { FormattedMessage } from '@kbn/i18n-react'; import { UserAvatar } from '@kbn/user-profile-components'; import { noop } from 'lodash'; -import { useGetUserProfiles } from '../../../../detections/containers/detection_engine/alerts/use_get_user_profiles'; +import { useGetUserProfiles } from '../../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'; import { useSetAlertAssignees } from '../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees'; import { ASSIGNEE_AVATAR_TEST_ID, diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees_popover.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees_popover.test.tsx index b9a5604657090..8604f888d8de1 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees_popover.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees_popover.test.tsx @@ -12,10 +12,10 @@ import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; import { ASSIGNEES_ADD_BUTTON_TEST_ID } from './test_ids'; import { AssigneesPopover } from './assignees_popover'; -import { useSuggestUsers } from '../../../../detections/containers/detection_engine/alerts/use_suggest_users'; +import { useSuggestUsers } from '../../../../detections/containers/detection_engine/user_profiles/use_suggest_users'; import { TestProviders } from '../../../../common/mock'; -jest.mock('../../../../detections/containers/detection_engine/alerts/use_suggest_users'); +jest.mock('../../../../detections/containers/detection_engine/user_profiles/use_suggest_users'); const mockUserProfiles: UserProfileWithAvatar[] = [ { diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees_popover.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees_popover.tsx index 59bd662f76e82..f37a0cc39ddb9 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees_popover.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees_popover.tsx @@ -13,7 +13,7 @@ import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; import { UserProfilesPopover } from '@kbn/user-profile-components'; import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; -import { useSuggestUsers } from '../../../../detections/containers/detection_engine/alerts/use_suggest_users'; +import { useSuggestUsers } from '../../../../detections/containers/detection_engine/user_profiles/use_suggest_users'; import { ASSIGNEES_ADD_BUTTON_TEST_ID } from './test_ids'; const PopoverButton: FC<{ togglePopover: () => void; isDisabled: boolean }> = memo( 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 6c2da93bcddfc..f448e1e38b060 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 @@ -73,17 +73,23 @@ jest.mock( } ); -jest.mock('../../../../detections/containers/detection_engine/alerts/use_get_user_profiles', () => { - return { - useGetUserProfiles: jest.fn().mockReturnValue({ loading: false, userProfiles: [] }), - }; -}); +jest.mock( + '../../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles', + () => { + return { + useGetUserProfiles: jest.fn().mockReturnValue({ loading: false, userProfiles: [] }), + }; + } +); -jest.mock('../../../../detections/containers/detection_engine/alerts/use_suggest_users', () => { - return { - useSuggestUsers: jest.fn().mockReturnValue({ loading: false, userProfiles: [] }), - }; -}); +jest.mock( + '../../../../detections/containers/detection_engine/user_profiles/use_suggest_users', + () => { + return { + useSuggestUsers: jest.fn().mockReturnValue({ loading: false, userProfiles: [] }), + }; + } +); jest.mock('../../../../common/hooks/use_experimental_features', () => ({ useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true), 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 5e7cc86ecc3ec..f140c010f898c 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,13 +30,16 @@ jest.mock('../../../common/containers/use_search_strategy', () => ({ useSearchStrategy: jest.fn(), })); -jest.mock('../../../detections/containers/detection_engine/alerts/use_get_user_profiles', () => { - return { - useGetUserProfiles: jest.fn().mockReturnValue({ loading: false, userProfiles: [] }), - }; -}); - -jest.mock('../../../detections/containers/detection_engine/alerts/use_suggest_users', () => { +jest.mock( + '../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles', + () => { + return { + useGetUserProfiles: jest.fn().mockReturnValue({ loading: false, userProfiles: [] }), + }; + } +); + +jest.mock('../../../detections/containers/detection_engine/user_profiles/use_suggest_users', () => { return { useSuggestUsers: jest.fn().mockReturnValue({ loading: false, userProfiles: [] }), }; From 2746bd0a4ee4a2b1c73df497cd22ff6758a20d58 Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Tue, 31 Oct 2023 23:02:54 +0100 Subject: [PATCH 09/53] Address UI/UX feedback (#170283) ## Summary - UI/UX feedback - Move assignees related UI in a common components folder Main ticket https://github.com/elastic/security-team/issues/2504 --- .../assignees/assignees_apply_panel.test.tsx | 137 ++++++++++++++++ .../assignees/assignees_apply_panel.tsx | 148 ++++++++++++++++++ .../assignees_avatars_panel.test.tsx | 81 ++++++++++ .../assignees/assignees_avatars_panel.tsx | 94 +++++++++++ .../assignees/assignees_popover.test.tsx | 90 +++++++++++ .../assignees/assignees_popover.tsx | 94 +++++++++++ .../common/components/assignees/constants.ts | 10 ++ .../common/components/assignees/mocks.ts | 27 ++++ .../common/components/assignees/test_ids.ts | 18 +++ .../components/assignees/translations.ts | 42 +++++ .../common/components/assignees/types.ts | 11 ++ .../common/components/assignees/utils.ts | 12 ++ .../filter_group/filter_by_assignees.test.tsx | 10 +- .../filter_group/filter_by_assignees.tsx | 116 +++++--------- .../alert_bulk_assignees.test.tsx | 7 +- .../bulk_actions/alert_bulk_assignees.tsx | 134 +++++----------- .../toolbar/bulk_actions/translations.ts | 34 ---- .../use_bulk_alert_assignees_items.test.tsx | 3 +- .../use_bulk_alert_assignees_items.tsx | 3 + .../alerts_table/default_config.tsx | 17 +- .../use_alert_assignees_actions.tsx | 3 +- .../detection_page_filters/index.tsx | 43 ++--- .../render_cell_value.tsx | 44 +----- .../public/detections/hooks/translations.ts | 7 + .../use_assignees_actions.tsx | 71 +++++++++ .../use_bulk_actions.tsx | 4 +- .../detection_engine/detection_engine.tsx | 44 +++--- .../right/components/assignees.test.tsx | 47 +++--- .../right/components/assignees.tsx | 148 +++++++----------- .../components/assignees_popover.test.tsx | 125 --------------- .../right/components/assignees_popover.tsx | 138 ---------------- .../right/components/header_title.tsx | 2 +- .../right/components/test_ids.ts | 5 - .../__snapshots__/index.test.tsx.snap | 10 +- .../event_details/expandable_event.tsx | 2 +- .../detection_alerts/alert_assignees.cy.ts | 1 - .../cypress/screens/alerts.ts | 7 +- .../cypress/tasks/alerts.ts | 3 +- 38 files changed, 1086 insertions(+), 706 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/components/assignees/assignees_apply_panel.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/assignees/assignees_apply_panel.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/assignees/assignees_avatars_panel.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/assignees/assignees_avatars_panel.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/assignees/assignees_popover.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/assignees/assignees_popover.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/assignees/constants.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/assignees/mocks.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/assignees/test_ids.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/assignees/translations.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/assignees/types.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/assignees/utils.ts create mode 100644 x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_assignees_actions.tsx delete mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees_popover.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees_popover.tsx 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..515175db475a8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/assignees/assignees_apply_panel.test.tsx @@ -0,0 +1,137 @@ +/* + * 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 { useGetUserProfiles } from '../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'; +import { useSuggestUsers } from '../../../detections/containers/detection_engine/user_profiles/use_suggest_users'; +import { TestProviders } from '../../mock'; +import * as i18n from './translations'; +import { mockUserProfiles } from './mocks'; + +jest.mock('../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'); +jest.mock('../../../detections/containers/detection_engine/user_profiles/use_suggest_users'); + +const renderAssigneesApplyPanel = ( + { + assignedUserIds, + showUnassignedOption, + onSelectionChange, + onAssigneesApply, + }: { + assignedUserIds: string[]; + showUnassignedOption?: boolean; + onSelectionChange?: () => void; + onAssigneesApply?: () => void; + } = { assignedUserIds: [] } +) => + render( + + + + ); + +describe('', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useGetUserProfiles as jest.Mock).mockReturnValue({ + loading: false, + userProfiles: mockUserProfiles, + }); + (useSuggestUsers as jest.Mock).mockReturnValue({ + loading: false, + userProfiles: 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 mockAssignedProfile = mockUserProfiles[0]; + (useGetUserProfiles as jest.Mock).mockReturnValue({ + loading: false, + userProfiles: [mockAssignedProfile], + }); + + const onAssigneesApplyMock = jest.fn(); + const { getByText, getByTestId } = renderAssigneesApplyPanel({ + assignedUserIds: [mockAssignedProfile.uid], + 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', () => { + (useGetUserProfiles as jest.Mock).mockReturnValue({ + loading: false, + userProfiles: [], + }); + + 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..9178584d25728 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/assignees/assignees_apply_panel.tsx @@ -0,0 +1,148 @@ +/* + * 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 * as i18n from './translations'; +import type { AssigneesIdsSelection, AssigneesProfilesSelection } from './types'; +import { NO_ASSIGNEES_VALUE } from './constants'; +import { useSuggestUsers } from '../../../detections/containers/detection_engine/user_profiles/use_suggest_users'; +import { useGetUserProfiles } from '../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'; +import { 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 user assignees selection for the alert + */ +export const AssigneesApplyPanel: FC = memo( + ({ + searchInputId, + assignedUserIds, + showUnassignedOption, + onSelectionChange, + onAssigneesApply, + }) => { + const existingIds = useMemo( + () => removeNoAssigneesSelection(assignedUserIds), + [assignedUserIds] + ); + const { loading: isLoadingAssignedUserProfiles, userProfiles: assignedUserProfiles } = + useGetUserProfiles(existingIds); + + const [searchTerm, setSearchTerm] = useState(''); + const { loading: isLoadingSuggestedUsers, userProfiles } = useSuggestUsers(searchTerm); + + const searchResultProfiles = useMemo(() => { + if (showUnassignedOption && isEmpty(searchTerm)) { + return [NO_ASSIGNEES_VALUE, ...userProfiles]; + } + return userProfiles; + }, [searchTerm, showUnassignedOption, userProfiles]); + + const [selectedAssignees, setSelectedAssignees] = useState([]); + useEffect(() => { + if (isLoadingAssignedUserProfiles) { + return; + } + const hasNoAssigneesSelection = assignedUserIds.find((uid) => uid === NO_ASSIGNEES_VALUE); + const newAssignees = + hasNoAssigneesSelection !== undefined + ? [NO_ASSIGNEES_VALUE, ...assignedUserProfiles] + : assignedUserProfiles; + setSelectedAssignees(newAssignees); + }, [assignedUserIds, assignedUserProfiles, isLoadingAssignedUserProfiles]); + + 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 = isLoadingAssignedUserProfiles || 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_avatars_panel.test.tsx b/x-pack/plugins/security_solution/public/common/components/assignees/assignees_avatars_panel.test.tsx new file mode 100644 index 0000000000000..de1f45479ebfc --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/assignees/assignees_avatars_panel.test.tsx @@ -0,0 +1,81 @@ +/* + * 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_AVATARS_COUNT_BADGE_TEST_ID, + ASSIGNEES_AVATARS_LOADING_TEST_ID, + ASSIGNEES_AVATARS_PANEL_TEST_ID, + ASSIGNEES_AVATAR_ITEM_TEST_ID, +} from './test_ids'; +import { AssigneesAvatarsPanel } from './assignees_avatars_panel'; + +import { useGetUserProfiles } from '../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'; +import { TestProviders } from '../../mock'; +import { mockUserProfiles } from './mocks'; + +jest.mock('../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'); + +const renderAssigneesAvatarsPanel = (assignedUserIds = ['user-id-1'], maxVisibleAvatars = 1) => + render( + + + + ); + +describe('', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useGetUserProfiles as jest.Mock).mockReturnValue({ + loading: false, + userProfiles: mockUserProfiles, + }); + }); + + it('should render component', () => { + const { getByTestId } = renderAssigneesAvatarsPanel(); + + expect(getByTestId(ASSIGNEES_AVATARS_PANEL_TEST_ID)).toBeInTheDocument(); + }); + + it('should render loading state', () => { + (useGetUserProfiles as jest.Mock).mockReturnValue({ + loading: true, + userProfiles: mockUserProfiles, + }); + const assignees = ['user-id-1', 'user-id-2', 'user-id-3']; + const { getByTestId, queryByTestId } = renderAssigneesAvatarsPanel(assignees); + + expect(getByTestId(ASSIGNEES_AVATARS_LOADING_TEST_ID)).toBeInTheDocument(); + expect(queryByTestId(ASSIGNEES_AVATARS_PANEL_TEST_ID)).not.toBeInTheDocument(); + }); + + it('should render avatars for all assignees', () => { + const assignees = ['user-id-1', 'user-id-2']; + const { getByTestId, queryByTestId } = renderAssigneesAvatarsPanel(assignees, 2); + + expect(getByTestId(ASSIGNEES_AVATAR_ITEM_TEST_ID('user1'))).toBeInTheDocument(); + expect(getByTestId(ASSIGNEES_AVATAR_ITEM_TEST_ID('user2'))).toBeInTheDocument(); + + expect(queryByTestId(ASSIGNEES_AVATARS_COUNT_BADGE_TEST_ID)).not.toBeInTheDocument(); + }); + + it('should render badge with number of assignees if exceeds `maxVisibleAvatars`', () => { + const assignees = ['user-id-1', 'user-id-2']; + const { getByTestId, queryByTestId } = renderAssigneesAvatarsPanel(assignees, 1); + + expect(getByTestId(ASSIGNEES_AVATARS_COUNT_BADGE_TEST_ID)).toBeInTheDocument(); + + expect(queryByTestId(ASSIGNEES_AVATAR_ITEM_TEST_ID('user1'))).not.toBeInTheDocument(); + expect(queryByTestId(ASSIGNEES_AVATAR_ITEM_TEST_ID('user2'))).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/assignees/assignees_avatars_panel.tsx b/x-pack/plugins/security_solution/public/common/components/assignees/assignees_avatars_panel.tsx new file mode 100644 index 0000000000000..adbcd2d2405a7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/assignees/assignees_avatars_panel.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 } from 'react'; +import React, { memo } from 'react'; + +import { + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiNotificationBadge, + EuiToolTip, +} from '@elastic/eui'; +import { UserAvatar } from '@kbn/user-profile-components'; + +import { useGetUserProfiles } from '../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'; +import { + ASSIGNEES_AVATAR_ITEM_TEST_ID, + ASSIGNEES_AVATARS_COUNT_BADGE_TEST_ID, + ASSIGNEES_AVATARS_LOADING_TEST_ID, + ASSIGNEES_AVATARS_PANEL_TEST_ID, +} from './test_ids'; + +export interface AssigneesProps { + /** + * The array of assignees + */ + assignedUserIds: string[]; + + /** + * Specifies how many avatars should be visible. + * If more assignees passed, then badge with number of assignees will be shown instead. + */ + maxVisibleAvatars?: number; +} + +/** + * Displays assignees avatars + */ +export const AssigneesAvatarsPanel: FC = memo( + ({ assignedUserIds, maxVisibleAvatars }) => { + const { loading: isLoading, userProfiles } = useGetUserProfiles(assignedUserIds); + const assignees = userProfiles?.filter((user) => assignedUserIds.includes(user.uid)) ?? []; + + if (isLoading) { + return ; + } + + if (maxVisibleAvatars && assignees.length > maxVisibleAvatars) { + return ( + ( +
{user.user.email ?? user.user.username}
+ ))} + repositionOnScroll={true} + > + + {assignees.length} + +
+ ); + } + + return ( + + {assignees.map((user) => ( + + + + ))} + + ); + } +); + +AssigneesAvatarsPanel.displayName = 'AssigneesAvatarsPanel'; 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..a284ad2b5680f --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/assignees/assignees_popover.test.tsx @@ -0,0 +1,90 @@ +/* + * 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 { useGetUserProfiles } from '../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'; +import { useSuggestUsers } from '../../../detections/containers/detection_engine/user_profiles/use_suggest_users'; +import { TestProviders } from '../../mock'; +import { mockUserProfiles } from './mocks'; +import { EuiButton } from '@elastic/eui'; + +jest.mock('../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'); +jest.mock('../../../detections/containers/detection_engine/user_profiles/use_suggest_users'); + +const MOCK_BUTTON_TEST_ID = 'mock-assignees-button'; + +const renderAssigneesPopover = ({ + assignedUserIds, + isPopoverOpen, +}: { + assignedUserIds: string[]; + isPopoverOpen: boolean; +}) => + render( + + } + isPopoverOpen={isPopoverOpen} + closePopover={jest.fn()} + /> + + ); + +describe('', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useGetUserProfiles as jest.Mock).mockReturnValue({ + loading: false, + userProfiles: mockUserProfiles, + }); + (useSuggestUsers as jest.Mock).mockReturnValue({ + loading: false, + userProfiles: 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..5e74ed3180ca1 --- /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 user assignees selection for the alert + */ +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..e01d619bc0c88 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/assignees/test_ids.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. + */ + +const PREFIX = 'securitySolutionAssignees'; + +/* Apply Panel */ +export const ASSIGNEES_APPLY_PANEL_TEST_ID = `${PREFIX}ApplyPanel`; +export const ASSIGNEES_APPLY_BUTTON_TEST_ID = `${PREFIX}ApplyButton`; + +/* Avatars */ +export const ASSIGNEES_AVATAR_ITEM_TEST_ID = (userName: string) => `${PREFIX}Avatar-${userName}`; +export const ASSIGNEES_AVATARS_PANEL_TEST_ID = `${PREFIX}AvatarsPanel`; +export const ASSIGNEES_AVATARS_COUNT_BADGE_TEST_ID = `${PREFIX}AvatarsCountBadge`; +export const ASSIGNEES_AVATARS_LOADING_TEST_ID = `${PREFIX}AvatarsLoading`; 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..042af89bd5371 --- /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 assignees', + } +); + +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.ts b/x-pack/plugins/security_solution/public/common/components/assignees/utils.ts new file mode 100644 index 0000000000000..307404229dbd4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/assignees/utils.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. + */ + +import { NO_ASSIGNEES_VALUE } from './constants'; +import type { AssigneesIdsSelection } from './types'; + +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/filter_by_assignees.test.tsx b/x-pack/plugins/security_solution/public/common/components/filter_group/filter_by_assignees.test.tsx index acf03eb29e116..cd571e8135140 100644 --- 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 @@ -11,6 +11,7 @@ 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'; const mockUserProfiles = [ { @@ -41,12 +42,15 @@ jest.mock('../../../detections/containers/detection_engine/user_profiles/use_sug }; }); -const renderFilterByAssigneesPopover = (alertAssignees?: string[], onUsersChange = jest.fn()) => +const renderFilterByAssigneesPopover = ( + alertAssignees: AssigneesIdsSelection[] = [], + onUsersChange = jest.fn() +) => render( ); 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 index 0908a9fd21abd..03aba6e7ef004 100644 --- 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 @@ -5,111 +5,71 @@ * 2.0. */ -import { isEqual } from 'lodash/fp'; import type { FC } from 'react'; -import React, { memo, useCallback, useEffect, useState } from 'react'; +import React, { memo, useCallback, useState } from 'react'; + import { i18n } from '@kbn/i18n'; -import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; -import { UserProfilesPopover } from '@kbn/user-profile-components'; +import { EuiFilterButton, EuiFilterGroup } from '@elastic/eui'; -import { EuiFilterButton } from '@elastic/eui'; -import { useSuggestUsers } from '../../../detections/containers/detection_engine/user_profiles/use_suggest_users'; import { TEST_IDS } from './constants'; +import { AssigneesPopover } from '../assignees/assignees_popover'; +import type { AssigneesIdsSelection } from '../assignees/types'; export interface FilterByAssigneesPopoverProps { /** * Ids of the users assigned to the alert */ - existingAssigneesIds?: string[]; + assignedUserIds: AssigneesIdsSelection[]; /** * Callback to handle changing of the assignees selection */ - onUsersChange?: (users: string[]) => void; + onSelectionChange?: (users: AssigneesIdsSelection[]) => void; } /** * The popover to filter alerts by assigned users */ export const FilterByAssigneesPopover: FC = memo( - ({ existingAssigneesIds, onUsersChange }) => { - const [searchTerm, setSearchTerm] = useState(''); - const { loading: isLoadingUsers, userProfiles } = useSuggestUsers(searchTerm); - + ({ assignedUserIds, onSelectionChange }) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); const togglePopover = useCallback(() => setIsPopoverOpen((value) => !value), []); - const [selectedAssignees, setSelectedAssignees] = useState([]); - useEffect(() => { - if (isLoadingUsers) { - return; - } - const assignees = userProfiles.filter((user) => existingAssigneesIds?.includes(user.uid)); - setSelectedAssignees(assignees); - }, [existingAssigneesIds, isLoadingUsers, userProfiles]); - - const handleSelectedAssignees = useCallback( - (newAssignees: UserProfileWithAvatar[]) => { - if (!isEqual(newAssignees, selectedAssignees)) { - setSelectedAssignees(newAssignees); - onUsersChange?.(newAssignees.map((user) => user.uid)); - } + const [selectedAssignees, setSelectedAssignees] = + useState(assignedUserIds); + const handleSelectionChange = useCallback( + (users: AssigneesIdsSelection[]) => { + setSelectedAssignees(users); + onSelectionChange?.(users); }, - [onUsersChange, selectedAssignees] - ); - - const selectedStatusMessage = useCallback( - (total: number) => - i18n.translate( - 'xpack.securitySolution.flyout.right.visualizations.assignees.totalUsersAssigned', - { - defaultMessage: '{total, plural, one {# filter} other {# filters}} selected', - values: { total }, - } - ), - [] + [onSelectionChange] ); return ( - + 0} + numActiveFilters={selectedAssignees.length} + > + {i18n.translate('xpack.securitySolution.filtersGroup.assignees.buttonTitle', { + defaultMessage: 'Assignees', + })} + } - )} - button={ - 0} - numActiveFilters={selectedAssignees.length} - > - {i18n.translate('xpack.securitySolution.filtersGroup.assignees.buttonTitle', { - defaultMessage: 'Assignees', - })} - - } - isOpen={isPopoverOpen} - closePopover={togglePopover} - panelStyle={{ - minWidth: 520, - }} - selectableProps={{ - onSearchChange: (term: string) => { - setSearchTerm(term); - }, - onChange: handleSelectedAssignees, - selectedStatusMessage, - options: userProfiles, - selectedOptions: selectedAssignees, - isLoading: isLoadingUsers, - height: 'full', - }} - /> + isPopoverOpen={isPopoverOpen} + closePopover={togglePopover} + onSelectionChange={handleSelectionChange} + /> + ); } ); 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 index f358885143b72..b4757a2b6c517 100644 --- 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 @@ -14,6 +14,7 @@ import { useSuggestUsers } from '../../../../detections/containers/detection_eng 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('../../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'); jest.mock('../../../../detections/containers/detection_engine/user_profiles/use_suggest_users'); @@ -87,7 +88,7 @@ describe('BulkAlertAssigneesPanel', () => { test('it renders', () => { const wrapper = renderAssigneesMenu(mockAlertsWithAssignees); - expect(wrapper.getByTestId('alert-assignees-update-button')).toBeInTheDocument(); + expect(wrapper.getByTestId(ASSIGNEES_APPLY_BUTTON_TEST_ID)).toBeInTheDocument(); expect(useSuggestUsers).toHaveBeenCalled(); }); @@ -104,7 +105,7 @@ describe('BulkAlertAssigneesPanel', () => { ); act(() => { - fireEvent.click(wrapper.getByTestId('alert-assignees-update-button')); + fireEvent.click(wrapper.getByTestId(ASSIGNEES_APPLY_BUTTON_TEST_ID)); }); expect(mockedClosePopover).toHaveBeenCalled(); expect(mockedOnSubmit).not.toHaveBeenCalled(); @@ -165,7 +166,7 @@ describe('BulkAlertAssigneesPanel', () => { }); act(() => { - fireEvent.click(wrapper.getByTestId('alert-assignees-update-button')); + fireEvent.click(wrapper.getByTestId(ASSIGNEES_APPLY_BUTTON_TEST_ID)); }); expect(mockedClosePopover).toHaveBeenCalled(); expect(mockedOnSubmit).toHaveBeenCalled(); 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 index 378aab17b19d7..f2449f0166149 100644 --- 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 @@ -5,18 +5,16 @@ * 2.0. */ -import { isEqual } from 'lodash/fp'; import { intersection } from 'lodash'; -import { EuiButton } from '@elastic/eui'; +import React, { memo, useCallback, useMemo } from 'react'; + import type { TimelineItem } from '@kbn/timelines-plugin/common'; -import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils'; -import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; -import { UserProfilesSelectable } from '@kbn/user-profile-components'; -import { useGetUserProfiles } from '../../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'; -import { useSuggestUsers } from '../../../../detections/containers/detection_engine/user_profiles/use_suggest_users'; -import * as i18n from './translations'; + 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[]; @@ -36,12 +34,7 @@ const BulkAlertAssigneesPanelComponent: React.FC { - const [searchTerm, setSearchTerm] = useState(''); - const { loading: isLoadingUsers, userProfiles } = useSuggestUsers(searchTerm); - - const [selectedAssignees, setSelectedAssignees] = useState([]); - - const originalIds = useMemo( + const assignedUserIds = useMemo( () => intersection( ...alertItems.map( @@ -52,91 +45,48 @@ const BulkAlertAssigneesPanelComponent: React.FC { - if (isLoadingAssignedUserProfiles) { - return; - } - setSelectedAssignees(assignedUserProfiles); - }, [assignedUserProfiles, isLoadingAssignedUserProfiles]); - - const onAssigneesUpdate = useCallback(async () => { - const updatedIds = selectedAssignees.map((user) => user?.uid); - - const assigneesToAddArray = updatedIds.filter((uid) => !originalIds.includes(uid)); - const assigneesToRemoveArray = originalIds.filter((uid) => !updatedIds.includes(uid)); - if (assigneesToAddArray.length === 0 && assigneesToRemoveArray.length === 0) { - closePopoverMenu(); - return; - } - - const ids = alertItems.map((item) => item._id); - const assignees = { - assignees_to_add: assigneesToAddArray, - assignees_to_remove: assigneesToRemoveArray, - }; - const onSuccess = () => { - if (refetchQuery) refetchQuery(); - if (refresh) refresh(); - if (clearSelection) clearSelection(); - }; - if (onSubmit != null) { - closePopoverMenu(); - await onSubmit(assignees, ids, onSuccess, setIsLoading); - } - }, [ - alertItems, - clearSelection, - closePopoverMenu, - originalIds, - onSubmit, - refetchQuery, - refresh, - selectedAssignees, - setIsLoading, - ]); + 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 handleSelectedAssignees = useCallback( - (newAssignees: UserProfileWithAvatar[]) => { - if (!isEqual(newAssignees, selectedAssignees)) { - setSelectedAssignees(newAssignees); + const ids = alertItems.map((item) => item._id); + const assignees = { + assignees_to_add: assigneesToAddArray, + assignees_to_remove: assigneesToRemoveArray, + }; + const onSuccess = () => { + if (refetchQuery) refetchQuery(); + if (refresh) refresh(); + if (clearSelection) clearSelection(); + }; + if (onSubmit != null) { + closePopoverMenu(); + await onSubmit(assignees, ids, onSuccess, setIsLoading); } }, - [selectedAssignees] - ); - - const selectedStatusMessage = useCallback( - (selectedCount: number) => i18n.ALERT_TOTAL_ASSIGNEES_FILTERED(selectedCount), - [] + [ + alertItems, + assignedUserIds, + clearSelection, + closePopoverMenu, + onSubmit, + refetchQuery, + refresh, + setIsLoading, + ] ); return (
- { - setSearchTerm(term); - }} - selectedStatusMessage={selectedStatusMessage} - options={userProfiles} - selectedOptions={selectedAssignees} - isLoading={isLoadingUsers} - height={'full'} - searchPlaceholder={i18n.ALERT_ASSIGNEES_SEARCH_USERS} - clearButtonLabel={i18n.ALERT_ASSIGNEES_CLEAR_FILTERS} - singleSelection={false} - nullOptionLabel={i18n.ALERT_ASSIGNEES_NO_ASSIGNEES} - /> - - {i18n.ALERT_ASSIGNEES_APPLY_BUTTON_MESSAGE} - +
); }; 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 30df492ee6aa2..98b5ba067ba86 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 @@ -226,13 +226,6 @@ export const UPDATE_ALERT_ASSIGNEES_FAILURE = i18n.translate( } ); -export const ALERT_ASSIGNEES_APPLY_BUTTON_MESSAGE = i18n.translate( - 'xpack.securitySolution.bulkActions.alertAssigneesApplyButtonMessage', - { - defaultMessage: 'Apply assignees', - } -); - export const ALERT_ASSIGNEES_CONTEXT_MENU_ITEM_TITLE = i18n.translate( 'xpack.securitySolution.bulkActions.alertAssigneesContextMenuItemTitle', { @@ -246,30 +239,3 @@ export const ALERT_ASSIGNEES_CONTEXT_MENU_ITEM_TOOLTIP_INFO = i18n.translate( defaultMessage: 'Change alert assignees options in Kibana Advanced Settings.', } ); - -export const ALERT_TOTAL_ASSIGNEES_FILTERED = (total: number) => - i18n.translate('xpack.securitySolution.bulkActions.totalFilteredUsers', { - defaultMessage: '{total, plural, one {# filter} other {# filters}} selected', - values: { total }, - }); - -export const ALERT_ASSIGNEES_SEARCH_USERS = i18n.translate( - 'xpack.securitySolution.bulkActions.userProfile.selectableSearchPlaceholder', - { - defaultMessage: 'Search users', - } -); - -export const ALERT_ASSIGNEES_CLEAR_FILTERS = i18n.translate( - 'xpack.securitySolution.bulkActions.userProfile.clearFilters', - { - defaultMessage: 'Clear filters', - } -); - -export const ALERT_ASSIGNEES_NO_ASSIGNEES = i18n.translate( - 'xpack.securitySolution.bulkActions.userProfile.noAssigneesLabel', - { - defaultMessage: 'No assignees', - } -); 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 index 732e42c32c1a7..0832fbfb867ef 100644 --- 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 @@ -17,6 +17,7 @@ import { useBulkAlertAssigneesItems } from './use_bulk_alert_assignees_items'; import { useSetAlertAssignees } from './use_set_alert_assignees'; import { useGetUserProfiles } from '../../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'; import { useSuggestUsers } from '../../../../detections/containers/detection_engine/user_profiles/use_suggest_users'; +import { ASSIGNEES_APPLY_BUTTON_TEST_ID } from '../../assignees/test_ids'; jest.mock('./use_set_alert_assignees'); jest.mock('../../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'); @@ -106,7 +107,7 @@ describe('useBulkAlertAssigneesItems', () => { fireEvent.click(wrapper.getByText('fakeUser2')); // Won't fire unless component assignees selection has been changed }); act(() => { - fireEvent.click(wrapper.getByTestId('alert-assignees-update-button')); + fireEvent.click(wrapper.getByTestId(ASSIGNEES_APPLY_BUTTON_TEST_ID)); }); expect(mockSetAlertAssignees).toHaveBeenCalled(); }); 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 index 07db301074aae..bdb9de99715ea 100644 --- 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 @@ -8,6 +8,7 @@ import { EuiFlexGroup, EuiIconTip, EuiFlexItem } from '@elastic/eui'; import type { RenderContentPanelProps } from '@kbn/triggers-actions-ui-plugin/public/types'; import React, { useCallback, useMemo } from 'react'; +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'; @@ -21,6 +22,7 @@ export interface UseBulkAlertAssigneesPanel { title: JSX.Element; 'data-test-subj': string; renderContent: (props: RenderContentPanelProps) => JSX.Element; + width?: number; } export const useBulkAlertAssigneesItems = ({ refetch }: UseBulkAlertAssigneesItemsProps) => { @@ -88,6 +90,7 @@ export const useBulkAlertAssigneesItems = ({ refetch }: UseBulkAlertAssigneesIte title: TitleContent, 'data-test-subj': 'alert-assignees-context-menu-panel', renderContent, + width: ASSIGNEES_PANEL_WIDTH, }, ], [TitleContent, renderContent] 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 4288231eae7d7..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 @@ -15,6 +15,7 @@ import { 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, @@ -153,17 +154,21 @@ export const buildThreatMatchFilter = (showOnlyThreatIndicatorAlerts: boolean): ] : []; -export const buildAlertAssigneesFilter = (assigneesIds: string[]): Filter[] => { +export const buildAlertAssigneesFilter = (assigneesIds: AssigneesIdsSelection[]): Filter[] => { if (!assigneesIds.length) { return []; } const combinedQuery = { bool: { - should: assigneesIds.map((id) => ({ - term: { - [ALERT_WORKFLOW_ASSIGNEE_IDS]: id, - }, - })), + should: assigneesIds.map((id) => + id + ? { + term: { + [ALERT_WORKFLOW_ASSIGNEE_IDS]: id, + }, + } + : { bool: { must_not: { exists: { field: ALERT_WORKFLOW_ASSIGNEE_IDS } } } } + ), }, }; 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 index 7353354bb8adb..ba93e610d1a8a 100644 --- 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 @@ -10,6 +10,7 @@ import { useMemo } from 'react'; 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'; @@ -72,7 +73,7 @@ export const useAlertAssigneesActions = ({ alertItems: alertAssigneeData, refresh, }); - return { title: panel.title, content, id: panel.id, width: 414 }; + return { title: panel.title, content, id: panel.id, width: ASSIGNEES_PANEL_WIDTH }; }), [alertAssigneeData, alertAssigneesPanels, closePopover, refresh] ); diff --git a/x-pack/plugins/security_solution/public/detections/components/detection_page_filters/index.tsx b/x-pack/plugins/security_solution/public/detections/components/detection_page_filters/index.tsx index c05ecd84e5d98..6e149b9866357 100644 --- a/x-pack/plugins/security_solution/public/detections/components/detection_page_filters/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/detection_page_filters/index.tsx @@ -9,9 +9,7 @@ import type { ComponentProps } from 'react'; import React, { useEffect, useState, useCallback } from 'react'; import type { Filter } from '@kbn/es-query'; import { isEqual } from 'lodash'; -import { EuiFilterGroup, EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui'; -import { css } from '@emotion/react'; -import { FilterByAssigneesPopover } from '../../../common/components/filter_group/filter_by_assignees'; +import { EuiFlexItem } from '@elastic/eui'; import { SourcererScopeName } from '../../../common/store/sourcerer/model'; import { FilterGroupLoading } from '../../../common/components/filter_group/loading'; import { useKibana } from '../../../common/lib/kibana'; @@ -22,10 +20,7 @@ import { useSourcererDataView } from '../../../common/containers/sourcerer'; type FilterItemSetProps = Omit< ComponentProps, 'initialControls' | 'dataViewId' -> & { - assignees?: string[]; - onAssigneesChange?: (users: string[]) => void; -}; +>; const SECURITY_ALERT_DATA_VIEW = { id: 'security_solution_alerts_dv', @@ -33,9 +28,8 @@ const SECURITY_ALERT_DATA_VIEW = { }; const FilterItemSetComponent = (props: FilterItemSetProps) => { - const { assignees, onAssigneesChange, onFilterChange, ...restFilterItemGroupProps } = props; + const { onFilterChange, ...restFilterItemGroupProps } = props; - const { euiTheme } = useEuiTheme(); const { indexPattern: { title }, dataViewId, @@ -95,31 +89,12 @@ const FilterItemSetComponent = (props: FilterItemSetProps) => { } return ( - - - - - - - - - - - - + ); }; 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 d9db2d1c80ad1..2bea9c5d91934 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 @@ -6,21 +6,14 @@ */ import type { EuiDataGridCellValueElementProps } from '@elastic/eui'; -import { - EuiIcon, - EuiToolTip, - EuiFlexGroup, - EuiFlexItem, - EuiNotificationBadge, - EuiLoadingSpinner, -} from '@elastic/eui'; +import { EuiIcon, EuiToolTip, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; import type { GetRenderCellValue } from '@kbn/triggers-actions-ui-plugin/public'; import { find, getOr } from 'lodash/fp'; import type { TimelineNonEcsData } from '@kbn/timelines-plugin/common'; import { tableDefaults, dataTableSelectors } from '@kbn/securitysolution-data-table'; import type { TableId } from '@kbn/securitysolution-data-table'; -import { UserAvatar } from '@kbn/user-profile-components'; +import { AssigneesAvatarsPanel } from '../../../common/components/assignees/assignees_avatars_panel'; import { useLicense } from '../../../common/hooks/use_license'; import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; import { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers'; @@ -44,7 +37,6 @@ import { SUPPRESSED_ALERT_TOOLTIP } from './translations'; import { VIEW_SELECTION } from '../../../../common/constants'; import { getAllFieldsByName } from '../../../common/containers/source'; import { eventRenderedViewColumns, getColumns } from './columns'; -import { useGetUserProfiles } from '../../containers/detection_engine/user_profiles/use_get_user_profiles'; /** * This implementation of `EuiDataGrid`'s `renderCellValue` @@ -81,38 +73,10 @@ export const RenderCellValue: React.FC actualAssignees.includes(user.uid)) ?? []; - if ( - columnId === SIGNAL_ASSIGNEE_IDS_FIELD_NAME && - (actualAssignees.length || isLoadingProfiles) - ) { - // Show spinner if loading profiles or if there are no fetched profiles yet - if (isLoadingProfiles || !assignees.length) { - return ; - } + if (columnId === SIGNAL_ASSIGNEE_IDS_FIELD_NAME && actualAssignees.length) { return ( - {assignees.length > 2 ? ( - ( -
{user.user.email ?? user.user.username}
- ))} - repositionOnScroll={true} - > - {assignees.length} -
- ) : ( - assignees.map((user) => ( - - )) - )} +
); } diff --git a/x-pack/plugins/security_solution/public/detections/hooks/translations.ts b/x-pack/plugins/security_solution/public/detections/hooks/translations.ts index 6fc3c49e65fc0..7e1e8aef3fbbd 100644 --- a/x-pack/plugins/security_solution/public/detections/hooks/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/hooks/translations.ts @@ -111,3 +111,10 @@ export const BULK_ACTION_CLOSE_SELECTED = i18n.translate( defaultMessage: 'Mark as closed', } ); + +export const BULK_REMOVE_ASSIGNEES_CONTEXT_MENU_TITLE = i18n.translate( + 'xpack.securitySolution.bulkActions.removeAssignessContextMenuTitle', + { + defaultMessage: 'Remove all assignees', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_assignees_actions.tsx b/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_assignees_actions.tsx new file mode 100644 index 0000000000000..526ea16b2f050 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_assignees_actions.tsx @@ -0,0 +1,71 @@ +/* + * 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 type { BulkActionsConfig } from '@kbn/triggers-actions-ui-plugin/public/types'; +import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils'; + +import { useSetAlertAssignees } from '../../../common/components/toolbar/bulk_actions/use_set_alert_assignees'; +import { useBulkAlertAssigneesItems } from '../../../common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items'; +import * as i18n from '../translations'; + +interface UseAssigneesActionItemsProps { + refetch?: () => void; +} + +export const useAssigneesActionItems = ({ refetch }: UseAssigneesActionItemsProps) => { + const setAlertAssignees = useSetAlertAssignees(); + + const { alertAssigneesItems: basicAssigneesItems, alertAssigneesPanels } = + useBulkAlertAssigneesItems({ + refetch, + }); + + const onActionClick: BulkActionsConfig['onClick'] = async ( + items, + isSelectAllChecked, + setAlertLoading, + clearSelection, + refresh + ) => { + const ids: string[] | undefined = 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 = { + assignees_to_add: [], + assignees_to_remove: assignedUserIds, + }; + if (setAlertAssignees) { + await setAlertAssignees(assignees, ids, refresh, setAlertLoading); + } + }; + + const alertAssigneesItems = [ + ...basicAssigneesItems, + ...[ + { + label: i18n.BULK_REMOVE_ASSIGNEES_CONTEXT_MENU_TITLE, + key: 'bulk-alert-assignees-remove-all-action', + 'data-test-subj': 'bulk-alert-assignees-remove-all-action', + disableOnQuery: false, + onClick: onActionClick, + }, + ], + ]; + + return { + alertAssigneesItems, + alertAssigneesPanels, + }; +}; 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 495f3eedeaaa7..b3c65c2f01f16 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 @@ -13,7 +13,6 @@ import type { Filter } from '@kbn/es-query'; import { useCallback } from 'react'; import type { TableId } from '@kbn/securitysolution-data-table'; import { useBulkAlertTagsItems } from '../../../common/components/toolbar/bulk_actions/use_bulk_alert_tags_items'; -import { useBulkAlertAssigneesItems } from '../../../common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items'; import type { inputsModel, State } from '../../../common/store'; import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; import { inputsSelectors } from '../../../common/store'; @@ -21,6 +20,7 @@ import { SourcererScopeName } from '../../../common/store/sourcerer/model'; import { useGlobalTime } from '../../../common/containers/use_global_time'; import { useAddBulkToTimelineAction } from '../../components/alerts_table/timeline_actions/use_add_bulk_to_timeline'; import { useBulkAlertActionItems } from './use_alert_actions'; +import { useAssigneesActionItems } from './use_assignees_actions'; // check to see if the query is a known "empty" shape export function isKnownEmptyQuery(query: QueryDslQueryContainer) { @@ -94,7 +94,7 @@ export const getBulkActionHook = refetch: refetchGlobalQuery, }); - const { alertAssigneesItems, alertAssigneesPanels } = useBulkAlertAssigneesItems({ + const { alertAssigneesItems, alertAssigneesPanels } = useAssigneesActionItems({ refetch: refetchGlobalQuery, }); 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 e4c76bc9296cb..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 @@ -32,6 +32,8 @@ import { 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'; @@ -137,9 +139,9 @@ const DetectionEnginePageComponent: React.FC = ({ const { loading: listsConfigLoading, needsConfiguration: needsListsConfiguration } = useListsConfig(); - const [assignees, setAssignees] = useState([]); + const [assignees, setAssignees] = useState([]); const handleSelectedAssignees = useCallback( - (newAssignees: string[]) => { + (newAssignees: AssigneesIdsSelection[]) => { if (!isEqual(newAssignees, assignees)) { setAssignees(newAssignees); } @@ -374,24 +376,20 @@ const DetectionEnginePageComponent: React.FC = ({ }} chainingSystem={'HIERARCHICAL'} onInit={setDetectionPageFilterHandler} - assignees={assignees} - onAssigneesChange={handleSelectedAssignees} /> ), [ - topLevelFilters, arePageFiltersEnabled, - statusFilter, + from, onFilterGroupChangedCallback, pageFiltersUpdateHandler, - showUpdating, - from, query, + showUpdating, + statusFilter, timelinesUi, to, + topLevelFilters, updatedAt, - assignees, - handleSelectedAssignees, ] ); @@ -466,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 index 778d30ccb10a7..0a3b8da5f13ac 100644 --- 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 @@ -7,15 +7,8 @@ import React from 'react'; import { render } from '@testing-library/react'; -import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; -import { - ASSIGNEES_ADD_BUTTON_TEST_ID, - ASSIGNEES_COUNT_BADGE_TEST_ID, - ASSIGNEES_TITLE_TEST_ID, - ASSIGNEES_VALUE_TEST_ID, - ASSIGNEE_AVATAR_TEST_ID, -} from './test_ids'; +import { ASSIGNEES_ADD_BUTTON_TEST_ID, ASSIGNEES_TITLE_TEST_ID } from './test_ids'; import { Assignees } from './assignees'; import { useGetUserProfiles } from '../../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'; @@ -23,12 +16,18 @@ import { useSuggestUsers } from '../../../../detections/containers/detection_eng 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, + ASSIGNEES_AVATARS_COUNT_BADGE_TEST_ID, + ASSIGNEES_AVATARS_PANEL_TEST_ID, + ASSIGNEES_AVATAR_ITEM_TEST_ID, +} from '../../../../common/components/assignees/test_ids'; jest.mock('../../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'); jest.mock('../../../../detections/containers/detection_engine/user_profiles/use_suggest_users'); jest.mock('../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees'); -const mockUserProfiles: UserProfileWithAvatar[] = [ +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: {} }, @@ -43,7 +42,7 @@ const renderAssignees = ( @@ -71,7 +70,7 @@ describe('', () => { const { getByTestId } = renderAssignees(); expect(getByTestId(ASSIGNEES_TITLE_TEST_ID)).toBeInTheDocument(); - expect(getByTestId(ASSIGNEES_VALUE_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(ASSIGNEES_AVATARS_PANEL_TEST_ID)).toBeInTheDocument(); expect(getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID)).toBeInTheDocument(); }); @@ -79,27 +78,33 @@ describe('', () => { const assignees = ['user-id-1', 'user-id-2']; const { getByTestId, queryByTestId } = renderAssignees('test-event', assignees); - expect(getByTestId(ASSIGNEE_AVATAR_TEST_ID('user1'))).toBeInTheDocument(); - expect(getByTestId(ASSIGNEE_AVATAR_TEST_ID('user2'))).toBeInTheDocument(); + expect(getByTestId(ASSIGNEES_AVATAR_ITEM_TEST_ID('user1'))).toBeInTheDocument(); + expect(getByTestId(ASSIGNEES_AVATAR_ITEM_TEST_ID('user2'))).toBeInTheDocument(); - expect(queryByTestId(ASSIGNEES_COUNT_BADGE_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(ASSIGNEES_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(ASSIGNEES_COUNT_BADGE_TEST_ID); + const assigneesCountBadge = getByTestId(ASSIGNEES_AVATARS_COUNT_BADGE_TEST_ID); expect(assigneesCountBadge).toBeInTheDocument(); expect(assigneesCountBadge).toHaveTextContent(`${assignees.length}`); - expect(queryByTestId(ASSIGNEE_AVATAR_TEST_ID('user1'))).not.toBeInTheDocument(); - expect(queryByTestId(ASSIGNEE_AVATAR_TEST_ID('user2'))).not.toBeInTheDocument(); - expect(queryByTestId(ASSIGNEE_AVATAR_TEST_ID('user3'))).not.toBeInTheDocument(); + expect(queryByTestId(ASSIGNEES_AVATAR_ITEM_TEST_ID('user1'))).not.toBeInTheDocument(); + expect(queryByTestId(ASSIGNEES_AVATAR_ITEM_TEST_ID('user2'))).not.toBeInTheDocument(); + expect(queryByTestId(ASSIGNEES_AVATAR_ITEM_TEST_ID('user3'))).not.toBeInTheDocument(); }); it('should call assignees update functionality with the right arguments', () => { - const assignees = ['user-id-1', 'user-id-2']; + const assignedProfiles = [mockUserProfiles[0], mockUserProfiles[1]]; + (useGetUserProfiles as jest.Mock).mockReturnValue({ + loading: false, + userProfiles: assignedProfiles, + }); + + const assignees = assignedProfiles.map((assignee) => assignee.uid); const { getByTestId, getByText } = renderAssignees('test-event', assignees); // Update assignees @@ -107,8 +112,8 @@ describe('', () => { getByText('User 1').click(); getByText('User 3').click(); - // Close assignees popover - getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID).click(); + // Apply assignees + getByTestId(ASSIGNEES_APPLY_BUTTON_TEST_ID).click(); expect(setAlertAssigneesMock).toHaveBeenCalledWith( { 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 index 5f4d05a8f4a98..5be130477783e 100644 --- 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 @@ -5,27 +5,40 @@ * 2.0. */ +import { noop } from 'lodash'; import type { FC } from 'react'; -import React, { memo, useCallback, useEffect, useState } from 'react'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiNotificationBadge, - EuiTitle, - EuiToolTip, -} from '@elastic/eui'; +import React, { memo, useCallback, useState } from 'react'; + +import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiTitle, EuiToolTip } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { UserAvatar } from '@kbn/user-profile-components'; -import { noop } from 'lodash'; -import { useGetUserProfiles } from '../../../../detections/containers/detection_engine/user_profiles/use_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 { AssigneesAvatarsPanel } from '../../../../common/components/assignees/assignees_avatars_panel'; import { useSetAlertAssignees } from '../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees'; -import { - ASSIGNEE_AVATAR_TEST_ID, - ASSIGNEES_TITLE_TEST_ID, - ASSIGNEES_VALUE_TEST_ID, - ASSIGNEES_COUNT_BADGE_TEST_ID, -} from './test_ids'; -import { AssigneesPopover } from './assignees_popover'; +import { ASSIGNEES_ADD_BUTTON_TEST_ID, ASSIGNEES_TITLE_TEST_ID } from './test_ids'; + +const UpdateAssigneesButton: FC<{ togglePopover: () => void }> = memo(({ togglePopover }) => ( + + + +)); +UpdateAssigneesButton.displayName = 'UpdateAssigneesButton'; export interface AssigneesProps { /** @@ -36,7 +49,7 @@ export interface AssigneesProps { /** * The array of ids of the users assigned to the alert */ - alertAssignees: string[]; + assignedUserIds: string[]; /** * Callback to handle the successful assignees update @@ -48,62 +61,37 @@ export interface AssigneesProps { * Document assignees details displayed in flyout right section header */ export const Assignees: FC = memo( - ({ eventId, alertAssignees, onAssigneesUpdated }) => { - const { userProfiles } = useGetUserProfiles(alertAssignees); + ({ eventId, assignedUserIds, onAssigneesUpdated }) => { const setAlertAssignees = useSetAlertAssignees(); - const assignees = userProfiles?.filter((user) => alertAssignees.includes(user.uid)) ?? []; - - const [selectedAssignees, setSelectedAssignees] = useState(); - const [needToUpdateAssignees, setNeedToUpdateAssignees] = useState(false); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const onSuccess = useCallback(() => { if (onAssigneesUpdated) onAssigneesUpdated(); }, [onAssigneesUpdated]); - const handleOnAlertAssigneesSubmit = useCallback(async () => { - if (setAlertAssignees && selectedAssignees) { - const existingIds = alertAssignees; - const updatedIds = selectedAssignees; - - const assigneesToAddArray = updatedIds.filter((uid) => !existingIds.includes(uid)); - const assigneesToRemoveArray = existingIds.filter((uid) => !updatedIds.includes(uid)); - - const assigneesToUpdate = { - assignees_to_add: assigneesToAddArray, - assignees_to_remove: assigneesToRemoveArray, - }; - - await setAlertAssignees(assigneesToUpdate, [eventId], onSuccess, noop); - } - }, [alertAssignees, eventId, onSuccess, selectedAssignees, setAlertAssignees]); - const togglePopover = useCallback(() => { setIsPopoverOpen((value) => !value); - setNeedToUpdateAssignees(true); - }, []); - - const onClosePopover = useCallback(() => { - // Order matters here because needToUpdateAssignees will likely be true already - // from the togglePopover call when opening the popover, so if we set the popover to false - // first, we'll get a rerender and then get another after we set needToUpdateAssignees to true again - setNeedToUpdateAssignees(true); - setIsPopoverOpen(false); }, []); - const onUsersChange = useCallback((users: string[]) => { - setSelectedAssignees(users); - }, []); - - useEffect(() => { - // selectedAssignees will be undefined on initial render or a rerender occurs, so we only want to update the assignees - // after the users have been changed in some manner not when it is an initial value - if (isPopoverOpen === false && needToUpdateAssignees && selectedAssignees) { - setNeedToUpdateAssignees(false); - handleOnAlertAssigneesSubmit(); - } - }, [handleOnAlertAssigneesSubmit, isPopoverOpen, needToUpdateAssignees, selectedAssignees]); + 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 = { + assignees_to_add: assigneesToAddArray, + assignees_to_remove: assigneesToRemoveArray, + }; + + await setAlertAssignees(assigneesToUpdate, [eventId], onSuccess, noop); + } + }, + [assignedUserIds, eventId, onSuccess, setAlertAssignees] + ); return ( @@ -118,39 +106,15 @@ export const Assignees: FC = memo( - - {assignees.length > 2 ? ( - ( -
{user.user.email ?? user.user.username}
- ))} - repositionOnScroll={true} - > - - {assignees.length} - -
- ) : ( - assignees.map((user) => ( - - )) - )} -
+
} isPopoverOpen={isPopoverOpen} - onUsersChange={onUsersChange} - onClosePopover={onClosePopover} - togglePopover={togglePopover} + closePopover={togglePopover} + onAssigneesApply={onAssigneesApply} />
diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees_popover.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees_popover.test.tsx deleted file mode 100644 index 8604f888d8de1..0000000000000 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees_popover.test.tsx +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { render } from '@testing-library/react'; -import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; - -import { ASSIGNEES_ADD_BUTTON_TEST_ID } from './test_ids'; -import { AssigneesPopover } from './assignees_popover'; - -import { useSuggestUsers } from '../../../../detections/containers/detection_engine/user_profiles/use_suggest_users'; -import { TestProviders } from '../../../../common/mock'; - -jest.mock('../../../../detections/containers/detection_engine/user_profiles/use_suggest_users'); - -const mockUserProfiles: UserProfileWithAvatar[] = [ - { - 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 renderAssigneesPopover = ( - alertAssignees: string[], - isPopoverOpen: boolean, - onUsersChange = jest.fn(), - togglePopover = jest.fn(), - onClosePopover = jest.fn() -) => - render( - - - - ); - -describe('', () => { - beforeEach(() => { - jest.clearAllMocks(); - (useSuggestUsers as jest.Mock).mockReturnValue({ - loading: false, - userProfiles: mockUserProfiles, - }); - }); - - it('should render closed popover component', () => { - const { getByTestId, queryByTestId } = renderAssigneesPopover([], false); - - expect(getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID)).toBeInTheDocument(); - expect(queryByTestId('euiSelectableList')).not.toBeInTheDocument(); - }); - - it('should render opened popover component', () => { - const { getByTestId } = renderAssigneesPopover([], true); - - expect(getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID)).toBeInTheDocument(); - expect(getByTestId('euiSelectableList')).toBeInTheDocument(); - }); - - it('should render assignees', () => { - const { getByTestId } = renderAssigneesPopover([], 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'); - }); - - it('should call onUsersChange on clsing the popover', () => { - const onUsersChangeMock = jest.fn(); - const { getByText } = renderAssigneesPopover([], true, onUsersChangeMock); - - 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']], - [[]], - ]); - }); - - it('should call togglePopover on add button click', () => { - const togglePopoverMock = jest.fn(); - const { getByTestId } = renderAssigneesPopover([], false, jest.fn(), togglePopoverMock); - - getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID).click(); - - expect(togglePopoverMock).toHaveBeenCalledTimes(1); - }); -}); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees_popover.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees_popover.tsx deleted file mode 100644 index f37a0cc39ddb9..0000000000000 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees_popover.tsx +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { isEqual } from 'lodash/fp'; -import type { FC } from 'react'; -import React, { memo, useCallback, useEffect, useState } from 'react'; -import { i18n } from '@kbn/i18n'; -import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; -import { UserProfilesPopover } from '@kbn/user-profile-components'; - -import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; -import { useSuggestUsers } from '../../../../detections/containers/detection_engine/user_profiles/use_suggest_users'; -import { ASSIGNEES_ADD_BUTTON_TEST_ID } from './test_ids'; - -const PopoverButton: FC<{ togglePopover: () => void; isDisabled: boolean }> = memo( - ({ togglePopover, isDisabled }) => ( - - - - ) -); -PopoverButton.displayName = 'PopoverButton'; - -export interface AssigneesPopoverProps { - /** - * Ids of the users assigned to the alert - */ - existingAssigneesIds: string[]; - - /** - * Boolean to allow popover to be opened or closed - */ - isPopoverOpen: boolean; - - /** - * Callback to handle changing ot the assignees selection - */ - onUsersChange: (users: string[]) => void; - - /** - * Callback to handle clicking the add assignees button to indicate that user wants to open/close the popover - */ - togglePopover: () => void; - - /** - * Callback to handle hiding of the popover - */ - onClosePopover: () => void; -} - -/** - * The popover to allow user assignees selection for the alert - */ -export const AssigneesPopover: FC = memo( - ({ existingAssigneesIds, isPopoverOpen, onUsersChange, togglePopover, onClosePopover }) => { - const [searchTerm, setSearchTerm] = useState(''); - const { loading: isLoadingUsers, userProfiles } = useSuggestUsers(searchTerm); - - const [selectedAssignees, setSelectedAssignees] = useState([]); - useEffect(() => { - if (isLoadingUsers) { - return; - } - const assignees = userProfiles.filter((user) => existingAssigneesIds.includes(user.uid)); - setSelectedAssignees(assignees); - }, [existingAssigneesIds, isLoadingUsers, userProfiles]); - - const handleSelectedAssignees = useCallback( - (newAssignees: UserProfileWithAvatar[]) => { - if (!isEqual(newAssignees, selectedAssignees)) { - setSelectedAssignees(newAssignees); - onUsersChange(newAssignees.map((user) => user.uid)); - } - }, - [onUsersChange, selectedAssignees] - ); - - const selectedStatusMessage = useCallback( - (total: number) => - i18n.translate( - 'xpack.securitySolution.flyout.right.visualizations.assignees.totalUsersAssigned', - { - defaultMessage: '{total, plural, one {# filter} other {# filters}} selected', - values: { total }, - } - ), - [] - ); - - return ( - } - isOpen={isPopoverOpen} - closePopover={onClosePopover} - panelStyle={{ - minWidth: 414, - }} - selectableProps={{ - onSearchChange: (term: string) => { - setSearchTerm(term); - }, - onChange: handleSelectedAssignees, - selectedStatusMessage, - options: userProfiles, - selectedOptions: selectedAssignees, - isLoading: isLoadingUsers, - height: 'full', - }} - /> - ); - } -); - -AssigneesPopover.displayName = 'AssigneesPopover'; 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 3b6dfb6f784dc..33d602036d8da 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 @@ -164,7 +164,7 @@ export const HeaderTitle: VFC = memo( 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 220d4fb5b4cad..d003bd8b5ae35 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 @@ -23,7 +23,6 @@ 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_TITLE_TEST_ID = `${FLYOUT_HEADER_TEST_ID}AssigneesTitle`; -export const ASSIGNEES_VALUE_TEST_ID = `${FLYOUT_HEADER_TEST_ID}AssigneesValue`; export const ASSIGNEES_ADD_BUTTON_TEST_ID = `${FLYOUT_HEADER_TEST_ID}AssigneesAddButton`; /* About section */ @@ -150,7 +149,3 @@ export const RESPONSE_SECTION_HEADER_TEST_ID = RESPONSE_SECTION_TEST_ID + HEADER export const RESPONSE_SECTION_CONTENT_TEST_ID = RESPONSE_SECTION_TEST_ID + CONTENT_TEST_ID; export const RESPONSE_BUTTON_TEST_ID = `${RESPONSE_TEST_ID}Button` as const; export const RESPONSE_EMPTY_TEST_ID = `${RESPONSE_TEST_ID}Empty` as const; - -/* Alert Assignees */ -export const ASSIGNEE_AVATAR_TEST_ID = (userName: string) => `${PREFIX}AssigneeAvatar-${userName}`; -export const ASSIGNEES_COUNT_BADGE_TEST_ID = `${PREFIX}AssigneesCountBadge`; 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 16f27efbd1e00..c31331f455a7d 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 @@ -94,8 +94,9 @@ Array [
-
-
( diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/alert_assignees.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/alert_assignees.cy.ts index f8b8e7a9831b6..0427c17ebb213 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/alert_assignees.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/alert_assignees.cy.ts @@ -30,7 +30,6 @@ import { describe('Alert assigning', { tags: ['@ess', '@serverless'] }, () => { before(() => { cleanKibana(); - cy.task('esArchiverResetKibana'); }); beforeEach(() => { 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 4b21fdce16eab..16ea9be90008f 100644 --- a/x-pack/test/security_solution_cypress/cypress/screens/alerts.ts +++ b/x-pack/test/security_solution_cypress/cypress/screens/alerts.ts @@ -187,9 +187,8 @@ export const ALERT_ASSIGNING_CONTEXT_MENU_ITEM = export const ALERT_ASSIGNING_SELECTABLE_MENU_ITEM = '[data-test-subj="alert-assignees-selectable-menu"]'; -export const ALERT_ASSIGNING_CONTEXT_MENU = '[data-test-subj="alert-assignees-selectable-menu"]'; - -export const ALERT_ASSIGNING_UPDATE_BUTTON = '[data-test-subj="alert-assignees-update-button"]'; +export const ALERT_ASSIGNING_UPDATE_BUTTON = + '[data-test-subj="securitySolutionAssigneesApplyButton"]'; export const ALERT_ASSIGNING_USER_AVATAR = (assignee: string) => - `[data-test-subj="alertTableAssigneeAvatar"][title='${assignee}']`; + `[data-test-subj="securitySolutionAssigneesAvatar-${assignee}"][title='${assignee}']`; diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/alerts.ts b/x-pack/test/security_solution_cypress/cypress/tasks/alerts.ts index c284012fef4e6..ac6f6a8814927 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/alerts.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/alerts.ts @@ -44,7 +44,6 @@ import { LEGEND_ACTIONS, SESSION_VIEWER_BUTTON, ALERT_ASSIGNING_CONTEXT_MENU_ITEM, - ALERT_ASSIGNING_CONTEXT_MENU, ALERT_ASSIGNING_SELECTABLE_MENU_ITEM, ALERT_ASSIGNING_UPDATE_BUTTON, ALERT_TAGGING_CONTEXT_MENU_ITEM, @@ -502,7 +501,7 @@ export const openAlertAssigningBulkActionMenu = () => { }; export const clickAlertAssignee = (assignee: string) => { - cy.get(ALERT_ASSIGNING_CONTEXT_MENU).contains(assignee).click(); + cy.get(ALERT_ASSIGNING_SELECTABLE_MENU_ITEM).contains(assignee).click(); }; export const updateAlertAssignees = () => { From 5574a7287907b099273cb9b08166e6f1cfc81e7b Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Wed, 1 Nov 2023 21:49:21 +0100 Subject: [PATCH 10/53] Efficiently render user display names instead of IDs in the alerts table (#170358) ## Summary Cherry-pick from @marshallmain branch to render cells efficiently inside the alerts table. Co-authored-by: Marshall Main --- .../common/types/timeline/cells/index.ts | 2 + .../hooks/use_bulk_get_user_profiles.ts | 42 +++++++++++ .../register_alerts_table_configuration.tsx | 11 ++- .../fetch_page_context.tsx | 36 ++++++++++ .../render_cell_value.tsx | 12 ++-- .../body/renderers/column_renderer.ts | 3 + .../timeline/body/renderers/index.ts | 2 + .../body/renderers/user_profile_renderer.tsx | 70 +++++++++++++++++++ .../cell_rendering/default_cell_renderer.tsx | 2 + .../alert_table_config_registry.ts | 4 +- .../sections/alerts_table/alerts_table.tsx | 8 ++- .../triggers_actions_ui/public/types.ts | 17 ++++- 12 files changed, 195 insertions(+), 14 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/hooks/use_bulk_get_user_profiles.ts create mode 100644 x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/fetch_page_context.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_profile_renderer.tsx 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/public/common/hooks/use_bulk_get_user_profiles.ts b/x-pack/plugins/security_solution/public/common/hooks/use_bulk_get_user_profiles.ts new file mode 100644 index 0000000000000..60b3cfed8243c --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/use_bulk_get_user_profiles.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 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'; + +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; + + return useQuery( + ['useBulkGetUserProfiles', ...uids], + async () => { + return bulkGetUserProfiles({ security, uids }); + }, + { + retry: false, + staleTime: Infinity, + } + ); +}; 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..316f1a441721e 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,13 +108,14 @@ const registerAlertsTableConfiguration = ( usePersistentControls: getPersistentControlsHook(TableId.alertsRiskInputs), sort, showInspectButton: true, + useFetchPageContext, }); }; -const registerIfNotAlready = ( +const registerIfNotAlready: ( registry: AlertsTableConfigurationRegistryContract, - registryArgs: AlertsTableConfigurationRegistry -) => { + registryArgs: AlertsTableConfigurationRegistry +) => void = (registry, registryArgs) => { if (!registry.has(registryArgs.id)) { registry.register(registryArgs); } 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..6967bc7a730c5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/fetch_page_context.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { UserProfile } from '@kbn/security-plugin/common'; +import type { UserProfileAvatarData } from '@kbn/user-profile-components'; +import type { PreFetchPageContext } from '@kbn/triggers-actions-ui-plugin/public/types'; +import { useBulkGetUserProfiles } from '../../../common/hooks/use_bulk_get_user_profiles'; + +export interface RenderCellValueContext { + profiles: Array> | 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_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 }); + return { profiles: result.data, isLoading: result.isLoading }; +}; 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 2bea9c5d91934..e8dd0a7d1c7d3 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 @@ -37,15 +37,16 @@ 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` * accepts `EuiDataGridCellValueElementProps`, plus `data` * from the TGrid */ -export const RenderCellValue: React.FC = ( - props -) => { +export const RenderCellValue: React.FC< + EuiDataGridCellValueElementProps & CellValueElementProps & { context?: RenderCellValueContext } +> = (props) => { const { columnId, rowIndex, scopeId } = props; const isTourAnchor = useMemo( () => @@ -114,7 +115,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(), []); @@ -192,10 +193,11 @@ export const getRenderCellValueHook = ({ scopeId={tableId} truncate={truncate} asPlainText={false} + context={context} /> ); }, - [browserFieldsByName, columnHeaders, browserFields] + [browserFieldsByName, columnHeaders, browserFields, context] ); return result; }; 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..c7d945bc60e43 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,6 +11,7 @@ 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; @@ -28,6 +29,7 @@ export interface ColumnRenderer { truncate, values, key, + context, }: { asPlainText?: boolean; className?: string; @@ -44,5 +46,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/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..50b5666b3b59e --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_profile_renderer.tsx @@ -0,0 +1,70 @@ +/* + * 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 { getUserDisplayName } from '@kbn/user-profile-components'; +import type { ColumnHeaderOptions, RowRenderer } from '../../../../../../common/types'; +import type { ColumnRenderer } from './column_renderer'; +import { plainColumnRenderer } from './plain_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) => profileUidColumns.includes(columnName), + 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 displayNames = values?.map((uid) => { + const userProfile = context?.profiles?.find((user) => uid === user.uid)?.user; + return userProfile ? getUserDisplayName(userProfile) : uid; + }); + return plainColumnRenderer.renderColumn({ + columnName, + eventId, + field, + isDetails, + isDraggable, + linkValues, + scopeId, + truncate, + values: displayNames, + }); + }, +}; 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..66d2b1551e487 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 ( @@ -62,6 +63,7 @@ export const DefaultCellRenderer: React.FC = ({ scopeId, truncate, values, + context, })} ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/alert_table_config_registry.ts b/x-pack/plugins/triggers_actions_ui/public/application/alert_table_config_registry.ts index 42b10da236e16..308bd8668b7c1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/alert_table_config_registry.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/alert_table_config_registry.ts @@ -15,7 +15,7 @@ import { export class AlertTableConfigRegistry { private readonly objectTypes: Map< string, - AlertsTableConfigurationRegistry | AlertsTableConfigurationRegistryWithActions + AlertsTableConfigurationRegistry | AlertsTableConfigurationRegistryWithActions > = new Map(); /** @@ -28,7 +28,7 @@ export class AlertTableConfigRegistry { /** * Registers an object type to the type registry */ - public register(objectType: AlertsTableConfigurationRegistry) { + public register(objectType: AlertsTableConfigurationRegistry) { if (this.has(objectType.id)) { throw new Error( i18n.translate( 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 3ba0f17e6188e..cba33a05733ab 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 @@ -90,6 +90,11 @@ const AlertsTable: React.FunctionComponent = (props: AlertsTab options: props.alertsTableConfiguration.useActionsColumn, }); + const renderCellContext = props.alertsTableConfiguration.useFetchPageContext?.({ + alerts, + columns: props.columns, + }); + const { isBulkActionsColumnActive, getBulkActionsLeadingControlColumn, @@ -318,9 +323,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/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index 9c9f08bc77b98..5e720852e13fe 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -570,12 +570,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> @@ -671,7 +681,7 @@ export interface UseFieldBrowserOptionsArgs { export type UseFieldBrowserOptions = (args: UseFieldBrowserOptionsArgs) => FieldBrowserOptions; -export interface AlertsTableConfigurationRegistry { +export interface AlertsTableConfigurationRegistry { id: string; cases?: { featureId: string; @@ -685,7 +695,7 @@ export interface AlertsTableConfigurationRegistry { footer: AlertTableFlyoutComponent; }; sort?: SortCombinations[]; - getRenderCellValue?: GetRenderCellValue; + getRenderCellValue?: GetRenderCellValue; useActionsColumn?: UseActionsColumnRegistry; useBulkActions?: UseBulkActionsRegistry; useCellActions?: UseCellActions; @@ -694,6 +704,7 @@ export interface AlertsTableConfigurationRegistry { }; useFieldBrowserOptions?: UseFieldBrowserOptions; showInspectButton?: boolean; + useFetchPageContext?: PreFetchPageContext; } export interface AlertsTableConfigurationRegistryWithActions From 664b83cf0c18b612c64368c324266b9ceda95303 Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Wed, 1 Nov 2023 22:05:52 +0100 Subject: [PATCH 11/53] Bring current user to the top of the user profiles list (#170321) ## Summary UI enhancement which shows current user at the top of the user profiles list. Main ticket https://github.com/elastic/security-team/issues/2504 --- .../assignees/assignees_apply_panel.tsx | 13 +++-- .../components/assignees/utils.test.tsx | 44 ++++++++++++++++ .../common/components/assignees/utils.ts | 47 +++++++++++++++++ .../use_bulk_alert_assignees_items.test.tsx | 6 +++ .../use_alert_assignees_actions.test.tsx | 6 +++ .../detection_engine/user_profiles/mock.ts | 7 +++ .../user_profiles/translations.ts | 7 ++- .../use_get_current_user.test.tsx | 49 ++++++++++++++++++ .../user_profiles/use_get_current_user.tsx | 50 +++++++++++++++++++ 9 files changed, 224 insertions(+), 5 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/components/assignees/utils.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_get_current_user.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_get_current_user.tsx 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 index 9178584d25728..b9b26f105e934 100644 --- 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 @@ -13,12 +13,13 @@ import { EuiButton } from '@elastic/eui'; import { UserProfilesSelectable } from '@kbn/user-profile-components'; import { isEmpty } from 'lodash'; +import { useGetCurrentUser } from '../../../detections/containers/detection_engine/user_profiles/use_get_current_user'; import * as i18n from './translations'; import type { AssigneesIdsSelection, AssigneesProfilesSelection } from './types'; import { NO_ASSIGNEES_VALUE } from './constants'; import { useSuggestUsers } from '../../../detections/containers/detection_engine/user_profiles/use_suggest_users'; import { useGetUserProfiles } from '../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'; -import { removeNoAssigneesSelection } from './utils'; +import { bringCurrentUserToFrontAndSort, removeNoAssigneesSelection } from './utils'; import { ASSIGNEES_APPLY_BUTTON_TEST_ID, ASSIGNEES_APPLY_PANEL_TEST_ID } from './test_ids'; export interface AssigneesApplyPanelProps { @@ -59,6 +60,7 @@ export const AssigneesApplyPanel: FC = memo( onSelectionChange, onAssigneesApply, }) => { + const { userProfile: currentUserProfile } = useGetCurrentUser(); const existingIds = useMemo( () => removeNoAssigneesSelection(assignedUserIds), [assignedUserIds] @@ -70,11 +72,14 @@ export const AssigneesApplyPanel: FC = memo( const { loading: isLoadingSuggestedUsers, userProfiles } = useSuggestUsers(searchTerm); const searchResultProfiles = useMemo(() => { + const sortedUsers = bringCurrentUserToFrontAndSort(currentUserProfile, userProfiles) ?? []; + if (showUnassignedOption && isEmpty(searchTerm)) { - return [NO_ASSIGNEES_VALUE, ...userProfiles]; + return [NO_ASSIGNEES_VALUE, ...sortedUsers]; } - return userProfiles; - }, [searchTerm, showUnassignedOption, userProfiles]); + + return sortedUsers; + }, [currentUserProfile, searchTerm, showUnassignedOption, userProfiles]); const [selectedAssignees, setSelectedAssignees] = useState([]); useEffect(() => { 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 index 307404229dbd4..9eae9503febd0 100644 --- a/x-pack/plugins/security_solution/public/common/components/assignees/utils.ts +++ b/x-pack/plugins/security_solution/public/common/components/assignees/utils.ts @@ -5,8 +5,55 @@ * 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/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 index 0832fbfb867ef..f3bb2d34bfa4e 100644 --- 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 @@ -15,11 +15,13 @@ import type { } from './use_bulk_alert_assignees_items'; import { useBulkAlertAssigneesItems } from './use_bulk_alert_assignees_items'; import { useSetAlertAssignees } from './use_set_alert_assignees'; +import { useGetCurrentUser } from '../../../../detections/containers/detection_engine/user_profiles/use_get_current_user'; import { useGetUserProfiles } from '../../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'; import { useSuggestUsers } from '../../../../detections/containers/detection_engine/user_profiles/use_suggest_users'; import { ASSIGNEES_APPLY_BUTTON_TEST_ID } from '../../assignees/test_ids'; jest.mock('./use_set_alert_assignees'); +jest.mock('../../../../detections/containers/detection_engine/user_profiles/use_get_current_user'); jest.mock('../../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'); jest.mock('../../../../detections/containers/detection_engine/user_profiles/use_suggest_users'); @@ -52,6 +54,10 @@ const renderPanel = (panel: UseBulkAlertAssigneesPanel) => { describe('useBulkAlertAssigneesItems', () => { beforeEach(() => { (useSetAlertAssignees as jest.Mock).mockReturnValue(jest.fn()); + (useGetCurrentUser as jest.Mock).mockReturnValue({ + loading: false, + userProfile: mockUserProfiles[0], + }); (useGetUserProfiles as jest.Mock).mockReturnValue({ loading: false, userProfiles: mockUserProfiles, 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 index 113cbb802e07c..f3b72a1986c91 100644 --- 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 @@ -16,11 +16,13 @@ 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 { useGetCurrentUser } from '../../../containers/detection_engine/user_profiles/use_get_current_user'; import { useGetUserProfiles } from '../../../containers/detection_engine/user_profiles/use_get_user_profiles'; import { useSuggestUsers } from '../../../containers/detection_engine/user_profiles/use_suggest_users'; jest.mock('../../../containers/detection_engine/alerts/use_alerts_privileges'); jest.mock('../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees'); +jest.mock('../../../containers/detection_engine/user_profiles/use_get_current_user'); jest.mock('../../../containers/detection_engine/user_profiles/use_get_user_profiles'); jest.mock('../../../containers/detection_engine/user_profiles/use_suggest_users'); @@ -161,6 +163,10 @@ describe('useAlertAssigneesActions', () => { it('should render the nested panel', async () => { (useSetAlertAssignees as jest.Mock).mockReturnValue(jest.fn()); + (useGetCurrentUser as jest.Mock).mockReturnValue({ + loading: false, + userProfile: mockUserProfiles[0], + }); (useGetUserProfiles as jest.Mock).mockReturnValue({ loading: false, userProfiles: mockUserProfiles, diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/mock.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/mock.ts index 930bd4d078433..11d1535cc5a50 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/mock.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/mock.ts @@ -5,6 +5,13 @@ * 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/detections/containers/detection_engine/user_profiles/translations.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/translations.ts index 4058d40a927f2..4f7a807d21091 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/translations.ts @@ -7,7 +7,12 @@ import { i18n } from '@kbn/i18n'; +export const CURRENT_USER_PROFILE_FAILURE = i18n.translate( + 'xpack.securitySolution.containers.detectionEngine.currentUserProfile.failure', + { defaultMessage: 'Failed to find current user' } +); + export const USER_PROFILES_FAILURE = i18n.translate( - 'xpack.securitySolution.containers.detectionEngine.userProfiles.title', + 'xpack.securitySolution.containers.detectionEngine.userProfiles.failure', { defaultMessage: 'Failed to find users' } ); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_get_current_user.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_get_current_user.test.tsx new file mode 100644 index 0000000000000..5cac233768158 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_get_current_user.test.tsx @@ -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 { renderHook } from '@testing-library/react-hooks'; +import { securityMock } from '@kbn/security-plugin/public/mocks'; + +import { mockCurrentUserProfile } from './mock'; +import { useGetCurrentUser } from './use_get_current_user'; +import { useKibana } from '../../../../common/lib/kibana'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import { useAppToastsMock } from '../../../../common/hooks/use_app_toasts.mock'; +import { createStartServicesMock } from '../../../../common/lib/kibana/kibana_react.mock'; + +jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../../common/hooks/use_app_toasts'); + +describe('useGetCurrentUser 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(() => useGetCurrentUser()); + await waitForNextUpdate(); + + expect(spyOnUserProfiles).toHaveBeenCalledTimes(1); + expect(result.current).toEqual({ + loading: false, + userProfile: mockCurrentUserProfile, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_get_current_user.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_get_current_user.tsx new file mode 100644 index 0000000000000..924c216cc04da --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_get_current_user.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 type { UserProfileWithAvatar } from '@kbn/user-profile-components'; +import { useEffect, useState } from 'react'; + +import { CURRENT_USER_PROFILE_FAILURE } from './translations'; +import { useKibana } from '../../../../common/lib/kibana'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; + +interface GetCurrentUserReturn { + loading: boolean; + userProfile?: UserProfileWithAvatar; +} + +export const useGetCurrentUser = (): GetCurrentUserReturn => { + const [loading, setLoading] = useState(false); + const [currentUser, setCurrentUser] = useState(undefined); + const { addError } = useAppToasts(); + const userProfiles = useKibana().services.security.userProfiles; + + useEffect(() => { + // isMounted tracks if a component is mounted before changing state + let isMounted = true; + setLoading(true); + const fetchData = async () => { + try { + const profile = await userProfiles.getCurrent({ dataPath: 'avatar' }); + if (isMounted) { + setCurrentUser(profile); + } + } catch (error) { + addError(error.message, { title: CURRENT_USER_PROFILE_FAILURE }); + } + if (isMounted) { + setLoading(false); + } + }; + fetchData(); + return () => { + // updates to show component is unmounted + isMounted = false; + }; + }, [addError, userProfiles]); + return { loading, userProfile: currentUser }; +}; From 6a1e419217cd9600735064384827c6e580dac0b9 Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Wed, 1 Nov 2023 22:22:56 +0100 Subject: [PATCH 12/53] Move user profiles into a common folder --- .../assignees/assignees_apply_panel.test.tsx | 8 ++++---- .../components/assignees/assignees_apply_panel.tsx | 6 +++--- .../assignees/assignees_avatars_panel.test.tsx | 2 +- .../components/assignees/assignees_avatars_panel.tsx | 2 +- .../components/assignees/assignees_popover.test.tsx | 8 ++++---- .../bulk_actions/alert_bulk_assignees.test.tsx | 8 ++++---- .../use_bulk_alert_assignees_items.test.tsx | 12 ++++++------ .../components}/user_profiles/__mocks__/api.ts | 0 .../components}/user_profiles/api.test.ts | 4 ++-- .../components}/user_profiles/api.ts | 4 ++-- .../components}/user_profiles/mock.ts | 0 .../components}/user_profiles/translations.ts | 0 .../components}/user_profiles/types.ts | 0 .../user_profiles/use_get_current_user.test.tsx | 12 ++++++------ .../user_profiles/use_get_current_user.tsx | 4 ++-- .../user_profiles/use_get_user_profiles.test.tsx | 12 ++++++------ .../user_profiles/use_get_user_profiles.tsx | 4 ++-- .../user_profiles/use_suggest_users.test.tsx | 6 +++--- .../components}/user_profiles/use_suggest_users.tsx | 2 +- .../use_alert_assignees_actions.test.tsx | 12 ++++++------ .../right/components/assignees.test.tsx | 8 ++++---- 21 files changed, 57 insertions(+), 57 deletions(-) rename x-pack/plugins/security_solution/public/{detections/containers/detection_engine => common/components}/user_profiles/__mocks__/api.ts (100%) rename x-pack/plugins/security_solution/public/{detections/containers/detection_engine => common/components}/user_profiles/api.test.ts (92%) rename x-pack/plugins/security_solution/public/{detections/containers/detection_engine => common/components}/user_profiles/api.ts (89%) rename x-pack/plugins/security_solution/public/{detections/containers/detection_engine => common/components}/user_profiles/mock.ts (100%) rename x-pack/plugins/security_solution/public/{detections/containers/detection_engine => common/components}/user_profiles/translations.ts (100%) rename x-pack/plugins/security_solution/public/{detections/containers/detection_engine => common/components}/user_profiles/types.ts (100%) rename x-pack/plugins/security_solution/public/{detections/containers/detection_engine => common/components}/user_profiles/use_get_current_user.test.tsx (79%) rename x-pack/plugins/security_solution/public/{detections/containers/detection_engine => common/components}/user_profiles/use_get_current_user.tsx (92%) rename x-pack/plugins/security_solution/public/{detections/containers/detection_engine => common/components}/user_profiles/use_get_user_profiles.test.tsx (79%) rename x-pack/plugins/security_solution/public/{detections/containers/detection_engine => common/components}/user_profiles/use_get_user_profiles.tsx (92%) rename x-pack/plugins/security_solution/public/{detections/containers/detection_engine => common/components}/user_profiles/use_suggest_users.test.tsx (84%) rename x-pack/plugins/security_solution/public/{detections/containers/detection_engine => common/components}/user_profiles/use_suggest_users.tsx (95%) 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 index 515175db475a8..6cbc3e929abae 100644 --- 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 @@ -11,14 +11,14 @@ 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 { useGetUserProfiles } from '../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'; -import { useSuggestUsers } from '../../../detections/containers/detection_engine/user_profiles/use_suggest_users'; +import { useGetUserProfiles } from '../user_profiles/use_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('../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'); -jest.mock('../../../detections/containers/detection_engine/user_profiles/use_suggest_users'); +jest.mock('../user_profiles/use_get_user_profiles'); +jest.mock('../user_profiles/use_suggest_users'); const renderAssigneesApplyPanel = ( { 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 index b9b26f105e934..1538bbbad2cc9 100644 --- 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 @@ -13,12 +13,12 @@ import { EuiButton } from '@elastic/eui'; import { UserProfilesSelectable } from '@kbn/user-profile-components'; import { isEmpty } from 'lodash'; -import { useGetCurrentUser } from '../../../detections/containers/detection_engine/user_profiles/use_get_current_user'; +import { useGetCurrentUser } from '../user_profiles/use_get_current_user'; import * as i18n from './translations'; import type { AssigneesIdsSelection, AssigneesProfilesSelection } from './types'; import { NO_ASSIGNEES_VALUE } from './constants'; -import { useSuggestUsers } from '../../../detections/containers/detection_engine/user_profiles/use_suggest_users'; -import { useGetUserProfiles } from '../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'; +import { useSuggestUsers } from '../user_profiles/use_suggest_users'; +import { useGetUserProfiles } from '../user_profiles/use_get_user_profiles'; import { bringCurrentUserToFrontAndSort, removeNoAssigneesSelection } from './utils'; import { ASSIGNEES_APPLY_BUTTON_TEST_ID, ASSIGNEES_APPLY_PANEL_TEST_ID } from './test_ids'; diff --git a/x-pack/plugins/security_solution/public/common/components/assignees/assignees_avatars_panel.test.tsx b/x-pack/plugins/security_solution/public/common/components/assignees/assignees_avatars_panel.test.tsx index de1f45479ebfc..95698e55cb492 100644 --- a/x-pack/plugins/security_solution/public/common/components/assignees/assignees_avatars_panel.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/assignees/assignees_avatars_panel.test.tsx @@ -16,7 +16,7 @@ import { } from './test_ids'; import { AssigneesAvatarsPanel } from './assignees_avatars_panel'; -import { useGetUserProfiles } from '../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'; +import { useGetUserProfiles } from '../user_profiles/use_get_user_profiles'; import { TestProviders } from '../../mock'; import { mockUserProfiles } from './mocks'; diff --git a/x-pack/plugins/security_solution/public/common/components/assignees/assignees_avatars_panel.tsx b/x-pack/plugins/security_solution/public/common/components/assignees/assignees_avatars_panel.tsx index adbcd2d2405a7..010f7ab6885c6 100644 --- a/x-pack/plugins/security_solution/public/common/components/assignees/assignees_avatars_panel.tsx +++ b/x-pack/plugins/security_solution/public/common/components/assignees/assignees_avatars_panel.tsx @@ -17,7 +17,7 @@ import { } from '@elastic/eui'; import { UserAvatar } from '@kbn/user-profile-components'; -import { useGetUserProfiles } from '../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'; +import { useGetUserProfiles } from '../user_profiles/use_get_user_profiles'; import { ASSIGNEES_AVATAR_ITEM_TEST_ID, ASSIGNEES_AVATARS_COUNT_BADGE_TEST_ID, 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 index a284ad2b5680f..7ea0586c72e00 100644 --- 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 @@ -11,14 +11,14 @@ import { render } from '@testing-library/react'; import { ASSIGNEES_APPLY_PANEL_TEST_ID } from './test_ids'; import { AssigneesPopover } from './assignees_popover'; -import { useGetUserProfiles } from '../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'; -import { useSuggestUsers } from '../../../detections/containers/detection_engine/user_profiles/use_suggest_users'; +import { useGetUserProfiles } from '../user_profiles/use_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('../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'); -jest.mock('../../../detections/containers/detection_engine/user_profiles/use_suggest_users'); +jest.mock('../user_profiles/use_get_user_profiles'); +jest.mock('../user_profiles/use_suggest_users'); const MOCK_BUTTON_TEST_ID = 'mock-assignees-button'; 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 index b4757a2b6c517..595ecaf5bd083 100644 --- 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 @@ -9,15 +9,15 @@ 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 { useGetUserProfiles } from '../../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'; -import { useSuggestUsers } from '../../../../detections/containers/detection_engine/user_profiles/use_suggest_users'; +import { useGetUserProfiles } from '../../user_profiles/use_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('../../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'); -jest.mock('../../../../detections/containers/detection_engine/user_profiles/use_suggest_users'); +jest.mock('../../user_profiles/use_get_user_profiles'); +jest.mock('../../user_profiles/use_suggest_users'); const mockUserProfiles = [ { uid: 'user-id-1', enabled: true, user: { username: 'user1' }, data: {} }, 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 index f3bb2d34bfa4e..319be49096412 100644 --- 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 @@ -15,15 +15,15 @@ import type { } from './use_bulk_alert_assignees_items'; import { useBulkAlertAssigneesItems } from './use_bulk_alert_assignees_items'; import { useSetAlertAssignees } from './use_set_alert_assignees'; -import { useGetCurrentUser } from '../../../../detections/containers/detection_engine/user_profiles/use_get_current_user'; -import { useGetUserProfiles } from '../../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'; -import { useSuggestUsers } from '../../../../detections/containers/detection_engine/user_profiles/use_suggest_users'; +import { useGetCurrentUser } from '../../user_profiles/use_get_current_user'; +import { useGetUserProfiles } from '../../user_profiles/use_get_user_profiles'; +import { useSuggestUsers } from '../../user_profiles/use_suggest_users'; import { ASSIGNEES_APPLY_BUTTON_TEST_ID } from '../../assignees/test_ids'; jest.mock('./use_set_alert_assignees'); -jest.mock('../../../../detections/containers/detection_engine/user_profiles/use_get_current_user'); -jest.mock('../../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'); -jest.mock('../../../../detections/containers/detection_engine/user_profiles/use_suggest_users'); +jest.mock('../../user_profiles/use_get_current_user'); +jest.mock('../../user_profiles/use_get_user_profiles'); +jest.mock('../../user_profiles/use_suggest_users'); const mockUserProfiles = [ { uid: 'user-id-1', enabled: true, user: { username: 'fakeUser1' }, data: {} }, diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/__mocks__/api.ts b/x-pack/plugins/security_solution/public/common/components/user_profiles/__mocks__/api.ts similarity index 100% rename from x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/__mocks__/api.ts rename to x-pack/plugins/security_solution/public/common/components/user_profiles/__mocks__/api.ts diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/api.test.ts b/x-pack/plugins/security_solution/public/common/components/user_profiles/api.test.ts similarity index 92% rename from x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/api.test.ts rename to x-pack/plugins/security_solution/public/common/components/user_profiles/api.test.ts index 534bd398cf304..f893d7ae80a3d 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/api.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/user_profiles/api.test.ts @@ -9,10 +9,10 @@ import { coreMock } from '@kbn/core/public/mocks'; import { mockUserProfiles } from './mock'; import { suggestUsers } from './api'; -import { KibanaServices } from '../../../../common/lib/kibana'; +import { KibanaServices } from '../../lib/kibana'; const mockKibanaServices = KibanaServices.get as jest.Mock; -jest.mock('../../../../common/lib/kibana'); +jest.mock('../../lib/kibana'); const coreStartMock = coreMock.createStart({ basePath: '/mock' }); mockKibanaServices.mockReturnValue(coreStartMock); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/api.ts b/x-pack/plugins/security_solution/public/common/components/user_profiles/api.ts similarity index 89% rename from x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/api.ts rename to x-pack/plugins/security_solution/public/common/components/user_profiles/api.ts index 920c050a044c3..22340d25e0a57 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/api.ts +++ b/x-pack/plugins/security_solution/public/common/components/user_profiles/api.ts @@ -8,8 +8,8 @@ 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 '../../../../common/lib/kibana'; +import { DETECTION_ENGINE_ALERT_SUGGEST_USERS_URL } from '../../../../common/constants'; +import { KibanaServices } from '../../lib/kibana'; /** * Fetches suggested user profiles diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/mock.ts b/x-pack/plugins/security_solution/public/common/components/user_profiles/mock.ts similarity index 100% rename from x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/mock.ts rename to x-pack/plugins/security_solution/public/common/components/user_profiles/mock.ts diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/translations.ts b/x-pack/plugins/security_solution/public/common/components/user_profiles/translations.ts similarity index 100% rename from x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/translations.ts rename to x-pack/plugins/security_solution/public/common/components/user_profiles/translations.ts diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/types.ts b/x-pack/plugins/security_solution/public/common/components/user_profiles/types.ts similarity index 100% rename from x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/types.ts rename to x-pack/plugins/security_solution/public/common/components/user_profiles/types.ts diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_get_current_user.test.tsx b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_get_current_user.test.tsx similarity index 79% rename from x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_get_current_user.test.tsx rename to x-pack/plugins/security_solution/public/common/components/user_profiles/use_get_current_user.test.tsx index 5cac233768158..924af4495c0fa 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_get_current_user.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_get_current_user.test.tsx @@ -10,13 +10,13 @@ import { securityMock } from '@kbn/security-plugin/public/mocks'; import { mockCurrentUserProfile } from './mock'; import { useGetCurrentUser } from './use_get_current_user'; -import { useKibana } from '../../../../common/lib/kibana'; -import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; -import { useAppToastsMock } from '../../../../common/hooks/use_app_toasts.mock'; -import { createStartServicesMock } from '../../../../common/lib/kibana/kibana_react.mock'; +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'; -jest.mock('../../../../common/lib/kibana'); -jest.mock('../../../../common/hooks/use_app_toasts'); +jest.mock('../../lib/kibana'); +jest.mock('../../hooks/use_app_toasts'); describe('useGetCurrentUser hook', () => { let appToastsMock: jest.Mocked>; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_get_current_user.tsx b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_get_current_user.tsx similarity index 92% rename from x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_get_current_user.tsx rename to x-pack/plugins/security_solution/public/common/components/user_profiles/use_get_current_user.tsx index 924c216cc04da..a46ae9a2a633f 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_get_current_user.tsx +++ b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_get_current_user.tsx @@ -9,8 +9,8 @@ import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; import { useEffect, useState } from 'react'; import { CURRENT_USER_PROFILE_FAILURE } from './translations'; -import { useKibana } from '../../../../common/lib/kibana'; -import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import { useKibana } from '../../lib/kibana'; +import { useAppToasts } from '../../hooks/use_app_toasts'; interface GetCurrentUserReturn { loading: boolean; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_get_user_profiles.test.tsx b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_get_user_profiles.test.tsx similarity index 79% rename from x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_get_user_profiles.test.tsx rename to x-pack/plugins/security_solution/public/common/components/user_profiles/use_get_user_profiles.test.tsx index 2f88d2e80c714..1ade29a59f8e6 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_get_user_profiles.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_get_user_profiles.test.tsx @@ -10,13 +10,13 @@ import { securityMock } from '@kbn/security-plugin/public/mocks'; import { mockUserProfiles } from './mock'; import { useGetUserProfiles } from './use_get_user_profiles'; -import { useKibana } from '../../../../common/lib/kibana'; -import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; -import { useAppToastsMock } from '../../../../common/hooks/use_app_toasts.mock'; -import { createStartServicesMock } from '../../../../common/lib/kibana/kibana_react.mock'; +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'; -jest.mock('../../../../common/lib/kibana'); -jest.mock('../../../../common/hooks/use_app_toasts'); +jest.mock('../../lib/kibana'); +jest.mock('../../hooks/use_app_toasts'); describe('useGetUserProfiles hook', () => { let appToastsMock: jest.Mocked>; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_get_user_profiles.tsx b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_get_user_profiles.tsx similarity index 92% rename from x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_get_user_profiles.tsx rename to x-pack/plugins/security_solution/public/common/components/user_profiles/use_get_user_profiles.tsx index 3172d3e6d7062..800baa3c0a20f 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_get_user_profiles.tsx +++ b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_get_user_profiles.tsx @@ -9,8 +9,8 @@ import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; import { useEffect, useState } from 'react'; import { USER_PROFILES_FAILURE } from './translations'; -import { useKibana } from '../../../../common/lib/kibana'; -import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import { useKibana } from '../../lib/kibana'; +import { useAppToasts } from '../../hooks/use_app_toasts'; interface GetUserProfilesReturn { loading: boolean; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_suggest_users.test.tsx b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_suggest_users.test.tsx similarity index 84% rename from x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_suggest_users.test.tsx rename to x-pack/plugins/security_solution/public/common/components/user_profiles/use_suggest_users.test.tsx index d9af3eb8e56c7..a7f08a89fab1b 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_suggest_users.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_suggest_users.test.tsx @@ -10,11 +10,11 @@ import { useSuggestUsers } from './use_suggest_users'; import * as api from './api'; import { mockUserProfiles } from './mock'; -import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; -import { useAppToastsMock } from '../../../../common/hooks/use_app_toasts.mock'; +import { useAppToasts } from '../../hooks/use_app_toasts'; +import { useAppToastsMock } from '../../hooks/use_app_toasts.mock'; jest.mock('./api'); -jest.mock('../../../../common/hooks/use_app_toasts'); +jest.mock('../../hooks/use_app_toasts'); describe('useSuggestUsers hook', () => { let appToastsMock: jest.Mocked>; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_suggest_users.tsx b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_suggest_users.tsx similarity index 95% rename from x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_suggest_users.tsx rename to x-pack/plugins/security_solution/public/common/components/user_profiles/use_suggest_users.tsx index 7d678bc8438e9..b01f786de9393 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/user_profiles/use_suggest_users.tsx +++ b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_suggest_users.tsx @@ -10,7 +10,7 @@ import { useEffect, useState } from 'react'; import { suggestUsers } from './api'; import { USER_PROFILES_FAILURE } from './translations'; -import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import { useAppToasts } from '../../hooks/use_app_toasts'; interface SuggestUsersReturn { loading: boolean; 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 index f3b72a1986c91..444e1dadac90d 100644 --- 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 @@ -16,15 +16,15 @@ 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 { useGetCurrentUser } from '../../../containers/detection_engine/user_profiles/use_get_current_user'; -import { useGetUserProfiles } from '../../../containers/detection_engine/user_profiles/use_get_user_profiles'; -import { useSuggestUsers } from '../../../containers/detection_engine/user_profiles/use_suggest_users'; +import { useGetCurrentUser } from '../../../../common/components/user_profiles/use_get_current_user'; +import { useGetUserProfiles } from '../../../../common/components/user_profiles/use_get_user_profiles'; +import { useSuggestUsers } from '../../../../common/components/user_profiles/use_suggest_users'; jest.mock('../../../containers/detection_engine/alerts/use_alerts_privileges'); jest.mock('../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees'); -jest.mock('../../../containers/detection_engine/user_profiles/use_get_current_user'); -jest.mock('../../../containers/detection_engine/user_profiles/use_get_user_profiles'); -jest.mock('../../../containers/detection_engine/user_profiles/use_suggest_users'); +jest.mock('../../../../common/components/user_profiles/use_get_current_user'); +jest.mock('../../../../common/components/user_profiles/use_get_user_profiles'); +jest.mock('../../../../common/components/user_profiles/use_suggest_users'); const mockUserProfiles = [ { uid: 'user-id-1', enabled: true, user: { username: 'fakeUser1' }, data: {} }, 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 index 0a3b8da5f13ac..7479e1fb4a207 100644 --- 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 @@ -11,8 +11,8 @@ import { render } from '@testing-library/react'; import { ASSIGNEES_ADD_BUTTON_TEST_ID, ASSIGNEES_TITLE_TEST_ID } from './test_ids'; import { Assignees } from './assignees'; -import { useGetUserProfiles } from '../../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'; -import { useSuggestUsers } from '../../../../detections/containers/detection_engine/user_profiles/use_suggest_users'; +import { useGetUserProfiles } from '../../../../common/components/user_profiles/use_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'; @@ -23,8 +23,8 @@ import { ASSIGNEES_AVATAR_ITEM_TEST_ID, } from '../../../../common/components/assignees/test_ids'; -jest.mock('../../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'); -jest.mock('../../../../detections/containers/detection_engine/user_profiles/use_suggest_users'); +jest.mock('../../../../common/components/user_profiles/use_get_user_profiles'); +jest.mock('../../../../common/components/user_profiles/use_suggest_users'); jest.mock('../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees'); const mockUserProfiles = [ From c4a29a28afb8a1984c56dd6250d3524ba1bda040 Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Fri, 3 Nov 2023 11:42:21 +0100 Subject: [PATCH 13/53] Efficiently render user avatars (#170372) ## Summary Improved Assignees cell rendering within the alerts table. --- .../assignees/assignees_apply_panel.test.tsx | 22 ++--- .../assignees/assignees_apply_panel.tsx | 12 +-- .../assignees_avatars_panel.test.tsx | 81 ---------------- .../assignees/assignees_avatars_panel.tsx | 94 ------------------- .../assignees/assignees_popover.test.tsx | 14 +-- .../common/components/assignees/test_ids.ts | 6 -- .../filter_group/filter_by_assignees.test.tsx | 2 +- .../components/user_profiles/test_ids.ts | 13 +++ .../components/user_profiles/translations.ts | 13 ++- .../users_avatars_panel.test.tsx | 58 ++++++++++++ .../user_profiles/users_avatars_panel.tsx | 80 ++++++++++++++++ .../detection_page_filters/index.test.tsx | 2 +- .../fetch_page_context.tsx | 17 +++- .../render_cell_value.tsx | 27 +----- .../right/components/assignees.test.tsx | 40 ++++---- .../right/components/assignees.tsx | 6 +- .../__snapshots__/index.test.tsx.snap | 4 +- .../side_panel/event_details/index.test.tsx | 26 ++--- .../components/side_panel/index.test.tsx | 17 ++-- .../body/renderers/user_profile_renderer.tsx | 22 +---- .../cypress/screens/alerts.ts | 2 +- 21 files changed, 252 insertions(+), 306 deletions(-) delete mode 100644 x-pack/plugins/security_solution/public/common/components/assignees/assignees_avatars_panel.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/common/components/assignees/assignees_avatars_panel.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/user_profiles/test_ids.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/user_profiles/users_avatars_panel.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/user_profiles/users_avatars_panel.tsx 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 index 6cbc3e929abae..f0f31c4ae5eaa 100644 --- 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 @@ -32,8 +32,13 @@ const renderAssigneesApplyPanel = ( onSelectionChange?: () => void; onAssigneesApply?: () => void; } = { assignedUserIds: [] } -) => - render( +) => { + const assignedProfiles = mockUserProfiles.filter((user) => assignedUserIds.includes(user.uid)); + (useGetUserProfiles as jest.Mock).mockReturnValue({ + loading: false, + userProfiles: assignedProfiles, + }); + return render( ); +}; describe('', () => { beforeEach(() => { jest.clearAllMocks(); - (useGetUserProfiles as jest.Mock).mockReturnValue({ - loading: false, - userProfiles: mockUserProfiles, - }); (useSuggestUsers as jest.Mock).mockReturnValue({ loading: false, userProfiles: mockUserProfiles, @@ -86,15 +88,9 @@ describe('', () => { }); it('should call `onAssigneesApply` on apply button click', () => { - const mockAssignedProfile = mockUserProfiles[0]; - (useGetUserProfiles as jest.Mock).mockReturnValue({ - loading: false, - userProfiles: [mockAssignedProfile], - }); - const onAssigneesApplyMock = jest.fn(); const { getByText, getByTestId } = renderAssigneesApplyPanel({ - assignedUserIds: [mockAssignedProfile.uid], + assignedUserIds: ['user-id-1'], onAssigneesApply: onAssigneesApplyMock, }); 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 index 1538bbbad2cc9..9c6a167f55e56 100644 --- 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 @@ -65,7 +65,7 @@ export const AssigneesApplyPanel: FC = memo( () => removeNoAssigneesSelection(assignedUserIds), [assignedUserIds] ); - const { loading: isLoadingAssignedUserProfiles, userProfiles: assignedUserProfiles } = + const { loading: isLoadingAssignedUsers, userProfiles: assignedUsers } = useGetUserProfiles(existingIds); const [searchTerm, setSearchTerm] = useState(''); @@ -83,16 +83,16 @@ export const AssigneesApplyPanel: FC = memo( const [selectedAssignees, setSelectedAssignees] = useState([]); useEffect(() => { - if (isLoadingAssignedUserProfiles) { + if (isLoadingAssignedUsers) { return; } const hasNoAssigneesSelection = assignedUserIds.find((uid) => uid === NO_ASSIGNEES_VALUE); const newAssignees = hasNoAssigneesSelection !== undefined - ? [NO_ASSIGNEES_VALUE, ...assignedUserProfiles] - : assignedUserProfiles; + ? [NO_ASSIGNEES_VALUE, ...assignedUsers] + : assignedUsers; setSelectedAssignees(newAssignees); - }, [assignedUserIds, assignedUserProfiles, isLoadingAssignedUserProfiles]); + }, [assignedUserIds, assignedUsers, isLoadingAssignedUsers]); const handleSelectedAssignees = useCallback( (newAssignees: AssigneesProfilesSelection[]) => { @@ -114,7 +114,7 @@ export const AssigneesApplyPanel: FC = memo( [] ); - const isLoading = isLoadingAssignedUserProfiles || isLoadingSuggestedUsers; + const isLoading = isLoadingAssignedUsers || isLoadingSuggestedUsers; return (
diff --git a/x-pack/plugins/security_solution/public/common/components/assignees/assignees_avatars_panel.test.tsx b/x-pack/plugins/security_solution/public/common/components/assignees/assignees_avatars_panel.test.tsx deleted file mode 100644 index 95698e55cb492..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/assignees/assignees_avatars_panel.test.tsx +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { render } from '@testing-library/react'; - -import { - ASSIGNEES_AVATARS_COUNT_BADGE_TEST_ID, - ASSIGNEES_AVATARS_LOADING_TEST_ID, - ASSIGNEES_AVATARS_PANEL_TEST_ID, - ASSIGNEES_AVATAR_ITEM_TEST_ID, -} from './test_ids'; -import { AssigneesAvatarsPanel } from './assignees_avatars_panel'; - -import { useGetUserProfiles } from '../user_profiles/use_get_user_profiles'; -import { TestProviders } from '../../mock'; -import { mockUserProfiles } from './mocks'; - -jest.mock('../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles'); - -const renderAssigneesAvatarsPanel = (assignedUserIds = ['user-id-1'], maxVisibleAvatars = 1) => - render( - - - - ); - -describe('', () => { - beforeEach(() => { - jest.clearAllMocks(); - (useGetUserProfiles as jest.Mock).mockReturnValue({ - loading: false, - userProfiles: mockUserProfiles, - }); - }); - - it('should render component', () => { - const { getByTestId } = renderAssigneesAvatarsPanel(); - - expect(getByTestId(ASSIGNEES_AVATARS_PANEL_TEST_ID)).toBeInTheDocument(); - }); - - it('should render loading state', () => { - (useGetUserProfiles as jest.Mock).mockReturnValue({ - loading: true, - userProfiles: mockUserProfiles, - }); - const assignees = ['user-id-1', 'user-id-2', 'user-id-3']; - const { getByTestId, queryByTestId } = renderAssigneesAvatarsPanel(assignees); - - expect(getByTestId(ASSIGNEES_AVATARS_LOADING_TEST_ID)).toBeInTheDocument(); - expect(queryByTestId(ASSIGNEES_AVATARS_PANEL_TEST_ID)).not.toBeInTheDocument(); - }); - - it('should render avatars for all assignees', () => { - const assignees = ['user-id-1', 'user-id-2']; - const { getByTestId, queryByTestId } = renderAssigneesAvatarsPanel(assignees, 2); - - expect(getByTestId(ASSIGNEES_AVATAR_ITEM_TEST_ID('user1'))).toBeInTheDocument(); - expect(getByTestId(ASSIGNEES_AVATAR_ITEM_TEST_ID('user2'))).toBeInTheDocument(); - - expect(queryByTestId(ASSIGNEES_AVATARS_COUNT_BADGE_TEST_ID)).not.toBeInTheDocument(); - }); - - it('should render badge with number of assignees if exceeds `maxVisibleAvatars`', () => { - const assignees = ['user-id-1', 'user-id-2']; - const { getByTestId, queryByTestId } = renderAssigneesAvatarsPanel(assignees, 1); - - expect(getByTestId(ASSIGNEES_AVATARS_COUNT_BADGE_TEST_ID)).toBeInTheDocument(); - - expect(queryByTestId(ASSIGNEES_AVATAR_ITEM_TEST_ID('user1'))).not.toBeInTheDocument(); - expect(queryByTestId(ASSIGNEES_AVATAR_ITEM_TEST_ID('user2'))).not.toBeInTheDocument(); - }); -}); diff --git a/x-pack/plugins/security_solution/public/common/components/assignees/assignees_avatars_panel.tsx b/x-pack/plugins/security_solution/public/common/components/assignees/assignees_avatars_panel.tsx deleted file mode 100644 index 010f7ab6885c6..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/assignees/assignees_avatars_panel.tsx +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { FC } from 'react'; -import React, { memo } from 'react'; - -import { - EuiFlexGroup, - EuiFlexItem, - EuiLoadingSpinner, - EuiNotificationBadge, - EuiToolTip, -} from '@elastic/eui'; -import { UserAvatar } from '@kbn/user-profile-components'; - -import { useGetUserProfiles } from '../user_profiles/use_get_user_profiles'; -import { - ASSIGNEES_AVATAR_ITEM_TEST_ID, - ASSIGNEES_AVATARS_COUNT_BADGE_TEST_ID, - ASSIGNEES_AVATARS_LOADING_TEST_ID, - ASSIGNEES_AVATARS_PANEL_TEST_ID, -} from './test_ids'; - -export interface AssigneesProps { - /** - * The array of assignees - */ - assignedUserIds: string[]; - - /** - * Specifies how many avatars should be visible. - * If more assignees passed, then badge with number of assignees will be shown instead. - */ - maxVisibleAvatars?: number; -} - -/** - * Displays assignees avatars - */ -export const AssigneesAvatarsPanel: FC = memo( - ({ assignedUserIds, maxVisibleAvatars }) => { - const { loading: isLoading, userProfiles } = useGetUserProfiles(assignedUserIds); - const assignees = userProfiles?.filter((user) => assignedUserIds.includes(user.uid)) ?? []; - - if (isLoading) { - return ; - } - - if (maxVisibleAvatars && assignees.length > maxVisibleAvatars) { - return ( - ( -
{user.user.email ?? user.user.username}
- ))} - repositionOnScroll={true} - > - - {assignees.length} - -
- ); - } - - return ( - - {assignees.map((user) => ( - - - - ))} - - ); - } -); - -AssigneesAvatarsPanel.displayName = 'AssigneesAvatarsPanel'; 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 index 7ea0586c72e00..d2a736d2cb2ae 100644 --- 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 @@ -28,8 +28,13 @@ const renderAssigneesPopover = ({ }: { assignedUserIds: string[]; isPopoverOpen: boolean; -}) => - render( +}) => { + const assignedProfiles = mockUserProfiles.filter((user) => assignedUserIds.includes(user.uid)); + (useGetUserProfiles as jest.Mock).mockReturnValue({ + loading: false, + userProfiles: assignedProfiles, + }); + return render( ); +}; describe('', () => { beforeEach(() => { jest.clearAllMocks(); - (useGetUserProfiles as jest.Mock).mockReturnValue({ - loading: false, - userProfiles: mockUserProfiles, - }); (useSuggestUsers as jest.Mock).mockReturnValue({ loading: false, userProfiles: mockUserProfiles, 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 index e01d619bc0c88..0842e8b5f3e98 100644 --- 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 @@ -10,9 +10,3 @@ const PREFIX = 'securitySolutionAssignees'; /* Apply Panel */ export const ASSIGNEES_APPLY_PANEL_TEST_ID = `${PREFIX}ApplyPanel`; export const ASSIGNEES_APPLY_BUTTON_TEST_ID = `${PREFIX}ApplyButton`; - -/* Avatars */ -export const ASSIGNEES_AVATAR_ITEM_TEST_ID = (userName: string) => `${PREFIX}Avatar-${userName}`; -export const ASSIGNEES_AVATARS_PANEL_TEST_ID = `${PREFIX}AvatarsPanel`; -export const ASSIGNEES_AVATARS_COUNT_BADGE_TEST_ID = `${PREFIX}AvatarsCountBadge`; -export const ASSIGNEES_AVATARS_LOADING_TEST_ID = `${PREFIX}AvatarsLoading`; 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 index cd571e8135140..45cbc200ea98c 100644 --- 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 @@ -33,7 +33,7 @@ const mockUserProfiles = [ data: {}, }, ]; -jest.mock('../../../detections/containers/detection_engine/user_profiles/use_suggest_users', () => { +jest.mock('../user_profiles/use_suggest_users', () => { return { useSuggestUsers: () => ({ loading: false, 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 index 4f7a807d21091..a1c5d065e1750 100644 --- 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 @@ -8,11 +8,18 @@ import { i18n } from '@kbn/i18n'; export const CURRENT_USER_PROFILE_FAILURE = i18n.translate( - 'xpack.securitySolution.containers.detectionEngine.currentUserProfile.failure', + 'xpack.securitySolution.userProfiles.fetchCurrentUserProfile.failure', { defaultMessage: 'Failed to find current user' } ); export const USER_PROFILES_FAILURE = i18n.translate( - 'xpack.securitySolution.containers.detectionEngine.userProfiles.failure', - { defaultMessage: 'Failed to find users' } + 'xpack.securitySolution.userProfiles.fetchUserProfiles.failure', + { + defaultMessage: 'Failed to find users', + } +); + +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/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/detections/components/detection_page_filters/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/detection_page_filters/index.test.tsx index 0def4b173450c..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 @@ -43,7 +43,7 @@ const mockUserProfiles = [ data: {}, }, ]; -jest.mock('../../containers/detection_engine/user_profiles/use_suggest_users', () => { +jest.mock('../../../common/components/user_profiles/use_suggest_users', () => { return { useSuggestUsers: () => ({ loading: false, 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 index 6967bc7a730c5..f7609b4f09c87 100644 --- 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 @@ -5,18 +5,21 @@ * 2.0. */ -import type { UserProfile } from '@kbn/security-plugin/common'; -import type { UserProfileAvatarData } from '@kbn/user-profile-components'; +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/hooks/use_bulk_get_user_profiles'; export interface RenderCellValueContext { - profiles: Array> | undefined; + 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_user']; +export const profileUidColumns = [ + 'kibana.alert.workflow_assignee_ids', + 'kibana.alert.workflow_user', +]; export const useFetchPageContext: PreFetchPageContext = ({ alerts, @@ -32,5 +35,9 @@ export const useFetchPageContext: PreFetchPageContext = }); }); const result = useBulkGetUserProfiles({ uids }); - return { profiles: result.data, isLoading: result.isLoading }; + 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 e8dd0a7d1c7d3..5b4f33f9fa0b4 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 @@ -13,7 +13,6 @@ import { find, getOr } from 'lodash/fp'; import type { TimelineNonEcsData } from '@kbn/timelines-plugin/common'; import { tableDefaults, dataTableSelectors } from '@kbn/securitysolution-data-table'; import type { TableId } from '@kbn/securitysolution-data-table'; -import { AssigneesAvatarsPanel } from '../../../common/components/assignees/assignees_avatars_panel'; import { useLicense } from '../../../common/hooks/use_license'; import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; import { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers'; @@ -24,10 +23,7 @@ import { AlertsCasesTourSteps, SecurityStepId, } from '../../../common/components/guided_onboarding_tour/tour_config'; -import { - SIGNAL_ASSIGNEE_IDS_FIELD_NAME, - SIGNAL_RULE_NAME_FIELD_NAME, -} from '../../../timelines/components/timeline/body/renderers/constants'; +import { SIGNAL_RULE_NAME_FIELD_NAME } from '../../../timelines/components/timeline/body/renderers/constants'; import { useSourcererDataView } from '../../../common/containers/sourcerer'; import type { CellValueElementProps } from '../../../timelines/components/timeline/cell_rendering'; @@ -44,9 +40,9 @@ import type { RenderCellValueContext } from './fetch_page_context'; * accepts `EuiDataGridCellValueElementProps`, plus `data` * from the TGrid */ -export const RenderCellValue: React.FC< - EuiDataGridCellValueElementProps & CellValueElementProps & { context?: RenderCellValueContext } -> = (props) => { +export const RenderCellValue: React.FC = ( + props +) => { const { columnId, rowIndex, scopeId } = props; const isTourAnchor = useMemo( () => @@ -67,21 +63,6 @@ export const RenderCellValue: React.FC< ? parseInt(ecsSuppressionCount, 10) : dataSuppressionCount; - const actualAssignees = useMemo(() => { - const ecsAssignees = props.ecsData?.kibana?.alert.workflow_assignee_ids; - const dataAssignees = find({ field: 'kibana.alert.workflow_assignee_ids' }, props.data) as - | string[] - | undefined; - return ecsAssignees ?? dataAssignees ?? []; - }, [props.data, props.ecsData?.kibana?.alert.workflow_assignee_ids]); - if (columnId === SIGNAL_ASSIGNEE_IDS_FIELD_NAME && actualAssignees.length) { - return ( - - - - ); - } - const component = ( - render( +) => { + const assignedProfiles = mockUserProfiles.filter((user) => alertAssignees.includes(user.uid)); + (useGetUserProfiles as jest.Mock).mockReturnValue({ + loading: false, + userProfiles: assignedProfiles, + }); + return render( ); +}; describe('', () => { let setAlertAssigneesMock: jest.Mocked; beforeEach(() => { jest.clearAllMocks(); - (useGetUserProfiles as jest.Mock).mockReturnValue({ - loading: false, - userProfiles: mockUserProfiles, - }); (useSuggestUsers as jest.Mock).mockReturnValue({ loading: false, userProfiles: mockUserProfiles, @@ -70,7 +72,7 @@ describe('', () => { const { getByTestId } = renderAssignees(); expect(getByTestId(ASSIGNEES_TITLE_TEST_ID)).toBeInTheDocument(); - expect(getByTestId(ASSIGNEES_AVATARS_PANEL_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(USERS_AVATARS_PANEL_TEST_ID)).toBeInTheDocument(); expect(getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID)).toBeInTheDocument(); }); @@ -78,23 +80,23 @@ describe('', () => { const assignees = ['user-id-1', 'user-id-2']; const { getByTestId, queryByTestId } = renderAssignees('test-event', assignees); - expect(getByTestId(ASSIGNEES_AVATAR_ITEM_TEST_ID('user1'))).toBeInTheDocument(); - expect(getByTestId(ASSIGNEES_AVATAR_ITEM_TEST_ID('user2'))).toBeInTheDocument(); + expect(getByTestId(USER_AVATAR_ITEM_TEST_ID('user1'))).toBeInTheDocument(); + expect(getByTestId(USER_AVATAR_ITEM_TEST_ID('user2'))).toBeInTheDocument(); - expect(queryByTestId(ASSIGNEES_AVATARS_COUNT_BADGE_TEST_ID)).not.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(ASSIGNEES_AVATARS_COUNT_BADGE_TEST_ID); + const assigneesCountBadge = getByTestId(USERS_AVATARS_COUNT_BADGE_TEST_ID); expect(assigneesCountBadge).toBeInTheDocument(); expect(assigneesCountBadge).toHaveTextContent(`${assignees.length}`); - expect(queryByTestId(ASSIGNEES_AVATAR_ITEM_TEST_ID('user1'))).not.toBeInTheDocument(); - expect(queryByTestId(ASSIGNEES_AVATAR_ITEM_TEST_ID('user2'))).not.toBeInTheDocument(); - expect(queryByTestId(ASSIGNEES_AVATAR_ITEM_TEST_ID('user3'))).not.toBeInTheDocument(); + 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', () => { 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 index 5be130477783e..74ddc65210300 100644 --- 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 @@ -13,10 +13,11 @@ import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiTitle, EuiToolTip } from ' import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import { useGetUserProfiles } from '../../../../common/components/user_profiles/use_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 { AssigneesAvatarsPanel } from '../../../../common/components/assignees/assignees_avatars_panel'; +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_TITLE_TEST_ID } from './test_ids'; @@ -63,6 +64,7 @@ export interface AssigneesProps { export const Assignees: FC = memo( ({ eventId, assignedUserIds, onAssigneesUpdated }) => { const setAlertAssignees = useSetAlertAssignees(); + const { userProfiles: assignedUsers } = useGetUserProfiles(assignedUserIds); const [isPopoverOpen, setIsPopoverOpen] = useState(false); @@ -106,7 +108,7 @@ export const Assignees: FC = memo( - +
{ - return { - useGetUserProfiles: jest.fn().mockReturnValue({ loading: false, userProfiles: [] }), - }; - } -); +jest.mock('../../../../common/components/user_profiles/use_get_user_profiles', () => { + return { + useGetUserProfiles: jest.fn().mockReturnValue({ loading: false, userProfiles: [] }), + }; +}); -jest.mock( - '../../../../detections/containers/detection_engine/user_profiles/use_suggest_users', - () => { - return { - useSuggestUsers: jest.fn().mockReturnValue({ loading: false, userProfiles: [] }), - }; - } -); +jest.mock('../../../../common/components/user_profiles/use_suggest_users', () => { + return { + useSuggestUsers: jest.fn().mockReturnValue({ loading: false, userProfiles: [] }), + }; +}); jest.mock('../../../../common/hooks/use_experimental_features', () => ({ useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true), 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 f140c010f898c..1afc6238887dc 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,16 +30,13 @@ jest.mock('../../../common/containers/use_search_strategy', () => ({ useSearchStrategy: jest.fn(), })); -jest.mock( - '../../../detections/containers/detection_engine/user_profiles/use_get_user_profiles', - () => { - return { - useGetUserProfiles: jest.fn().mockReturnValue({ loading: false, userProfiles: [] }), - }; - } -); - -jest.mock('../../../detections/containers/detection_engine/user_profiles/use_suggest_users', () => { +jest.mock('../../../common/components/user_profiles/use_get_user_profiles', () => { + return { + useGetUserProfiles: jest.fn().mockReturnValue({ loading: false, userProfiles: [] }), + }; +}); + +jest.mock('../../../common/components/user_profiles/use_suggest_users', () => { return { useSuggestUsers: jest.fn().mockReturnValue({ loading: false, userProfiles: [] }), }; 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 index 50b5666b3b59e..faea1227ea5e0 100644 --- 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 @@ -9,10 +9,9 @@ import { EuiLoadingSpinner } from '@elastic/eui'; import React from 'react'; import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; -import { getUserDisplayName } from '@kbn/user-profile-components'; +import { UsersAvatarsPanel } from '../../../../../common/components/user_profiles/users_avatars_panel'; import type { ColumnHeaderOptions, RowRenderer } from '../../../../../../common/types'; import type { ColumnRenderer } from './column_renderer'; -import { plainColumnRenderer } from './plain_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'; @@ -51,20 +50,9 @@ export const userProfileColumnRenderer: ColumnRenderer = { return ; } - const displayNames = values?.map((uid) => { - const userProfile = context?.profiles?.find((user) => uid === user.uid)?.user; - return userProfile ? getUserDisplayName(userProfile) : uid; - }); - return plainColumnRenderer.renderColumn({ - columnName, - eventId, - field, - isDetails, - isDraggable, - linkValues, - scopeId, - truncate, - values: displayNames, - }); + const userProfiles = + values?.map((uid) => context?.profiles?.find((user) => uid === user.uid)) ?? []; + + return ; }, }; 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 100dca83ed0f2..452b7f01b9bde 100644 --- a/x-pack/test/security_solution_cypress/cypress/screens/alerts.ts +++ b/x-pack/test/security_solution_cypress/cypress/screens/alerts.ts @@ -193,4 +193,4 @@ export const ALERT_ASSIGNING_UPDATE_BUTTON = '[data-test-subj="securitySolutionAssigneesApplyButton"]'; export const ALERT_ASSIGNING_USER_AVATAR = (assignee: string) => - `[data-test-subj="securitySolutionAssigneesAvatar-${assignee}"][title='${assignee}']`; + `[data-test-subj="securitySolutionUsersAvatar-${assignee}"][title='${assignee}']`; From 89ec60ac0a23c91870893a06ea13ac37cea02776 Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Fri, 3 Nov 2023 13:15:45 +0100 Subject: [PATCH 14/53] Make alerts table row to fit user profile avatars by default (#170505) ## Summary Allow alerts table to render user avatars without cutting them off. Broken: Screenshot 2023-11-03 at 12 12 05 Fixed: Screenshot 2023-11-03 at 12 11 52 Main ticket https://github.com/elastic/security-team/issues/2504 --- .../detections/components/alerts_table/index.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx index 4be4595e19afe..9dd627cec4444 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx @@ -197,14 +197,11 @@ export const AlertsTableComponent: FC = ({ [isEventRenderedView] ); - const rowHeightsOptions: EuiDataGridRowHeightsOptions | undefined = useMemo(() => { - if (isEventRenderedView) { - return { - defaultHeight: 'auto', - }; - } - return undefined; - }, [isEventRenderedView]); + const rowHeightsOptions: EuiDataGridRowHeightsOptions = useMemo(() => { + return { + defaultHeight: 'auto', + }; + }, []); const alertColumns = useMemo( () => (columns.length ? columns : getColumns(license)), From b6edb15c3d91b8a6045d408a73cac43968ccccd9 Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Fri, 3 Nov 2023 21:54:47 +0100 Subject: [PATCH 15/53] Use `useKibana` in user profiles hooks (#170523) ## Summary Update user profiles hooks to use `useQuery`. --- .../assignees/assignees_apply_panel.test.tsx | 26 +++++---- .../assignees/assignees_apply_panel.tsx | 17 +++--- .../assignees/assignees_popover.test.tsx | 20 ++++--- .../filter_group/filter_by_assignees.test.tsx | 30 +++++++--- .../alert_bulk_assignees.test.tsx | 20 ++++--- .../use_bulk_alert_assignees_items.test.tsx | 18 +++--- ...sx => use_bulk_get_user_profiles.test.tsx} | 20 ++++--- .../use_bulk_get_user_profiles.tsx} | 8 ++- .../use_get_current_user.test.tsx | 11 ++-- .../user_profiles/use_get_current_user.tsx | 58 ++++++++----------- .../user_profiles/use_get_user_profiles.tsx | 56 ------------------ .../user_profiles/use_suggest_users.test.tsx | 11 ++-- .../user_profiles/use_suggest_users.tsx | 57 +++++++++--------- .../use_alert_assignees_actions.test.tsx | 18 +++--- .../fetch_page_context.tsx | 2 +- .../right/components/assignees.test.tsx | 26 +++++---- .../right/components/assignees.tsx | 16 +++-- .../side_panel/event_details/index.test.tsx | 6 +- .../components/side_panel/index.test.tsx | 6 +- 19 files changed, 206 insertions(+), 220 deletions(-) rename x-pack/plugins/security_solution/public/common/components/user_profiles/{use_get_user_profiles.test.tsx => use_bulk_get_user_profiles.test.tsx} (76%) rename x-pack/plugins/security_solution/public/common/{hooks/use_bulk_get_user_profiles.ts => components/user_profiles/use_bulk_get_user_profiles.tsx} (80%) delete mode 100644 x-pack/plugins/security_solution/public/common/components/user_profiles/use_get_user_profiles.tsx 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 index f0f31c4ae5eaa..3582586b97a73 100644 --- 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 @@ -11,13 +11,15 @@ 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 { useGetUserProfiles } from '../user_profiles/use_get_user_profiles'; +import { useGetCurrentUser } from '../user_profiles/use_get_current_user'; +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_user_profiles'); +jest.mock('../user_profiles/use_get_current_user'); +jest.mock('../user_profiles/use_bulk_get_user_profiles'); jest.mock('../user_profiles/use_suggest_users'); const renderAssigneesApplyPanel = ( @@ -34,9 +36,9 @@ const renderAssigneesApplyPanel = ( } = { assignedUserIds: [] } ) => { const assignedProfiles = mockUserProfiles.filter((user) => assignedUserIds.includes(user.uid)); - (useGetUserProfiles as jest.Mock).mockReturnValue({ - loading: false, - userProfiles: assignedProfiles, + (useBulkGetUserProfiles as jest.Mock).mockReturnValue({ + isLoading: false, + data: assignedProfiles, }); return render( @@ -53,9 +55,13 @@ const renderAssigneesApplyPanel = ( describe('', () => { beforeEach(() => { jest.clearAllMocks(); + (useGetCurrentUser as jest.Mock).mockReturnValue({ + isLoading: false, + data: mockUserProfiles[0], + }); (useSuggestUsers as jest.Mock).mockReturnValue({ - loading: false, - userProfiles: mockUserProfiles, + isLoading: false, + data: mockUserProfiles, }); }); @@ -102,9 +108,9 @@ describe('', () => { }); it('should call `onSelectionChange` on user selection', () => { - (useGetUserProfiles as jest.Mock).mockReturnValue({ - loading: false, - userProfiles: [], + (useBulkGetUserProfiles as jest.Mock).mockReturnValue({ + isLoading: false, + data: [], }); const onSelectionChangeMock = jest.fn(); 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 index 9c6a167f55e56..c8fb5cd794b17 100644 --- 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 @@ -18,7 +18,7 @@ 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 { useGetUserProfiles } from '../user_profiles/use_get_user_profiles'; +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'; @@ -60,16 +60,19 @@ export const AssigneesApplyPanel: FC = memo( onSelectionChange, onAssigneesApply, }) => { - const { userProfile: currentUserProfile } = useGetCurrentUser(); + const { data: currentUserProfile } = useGetCurrentUser(); const existingIds = useMemo( - () => removeNoAssigneesSelection(assignedUserIds), + () => new Set(removeNoAssigneesSelection(assignedUserIds)), [assignedUserIds] ); - const { loading: isLoadingAssignedUsers, userProfiles: assignedUsers } = - useGetUserProfiles(existingIds); + const { isLoading: isLoadingAssignedUsers, data: assignedUsers } = useBulkGetUserProfiles({ + uids: existingIds, + }); const [searchTerm, setSearchTerm] = useState(''); - const { loading: isLoadingSuggestedUsers, userProfiles } = useSuggestUsers(searchTerm); + const { isLoading: isLoadingSuggestedUsers, data: userProfiles } = useSuggestUsers({ + searchTerm, + }); const searchResultProfiles = useMemo(() => { const sortedUsers = bringCurrentUserToFrontAndSort(currentUserProfile, userProfiles) ?? []; @@ -83,7 +86,7 @@ export const AssigneesApplyPanel: FC = memo( const [selectedAssignees, setSelectedAssignees] = useState([]); useEffect(() => { - if (isLoadingAssignedUsers) { + if (isLoadingAssignedUsers || !assignedUsers) { return; } const hasNoAssigneesSelection = assignedUserIds.find((uid) => uid === NO_ASSIGNEES_VALUE); 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 index d2a736d2cb2ae..5d39e739e2133 100644 --- 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 @@ -11,13 +11,15 @@ import { render } from '@testing-library/react'; import { ASSIGNEES_APPLY_PANEL_TEST_ID } from './test_ids'; import { AssigneesPopover } from './assignees_popover'; -import { useGetUserProfiles } from '../user_profiles/use_get_user_profiles'; +import { useGetCurrentUser } from '../user_profiles/use_get_current_user'; +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_user_profiles'); +jest.mock('../user_profiles/use_get_current_user'); +jest.mock('../user_profiles/use_bulk_get_user_profiles'); jest.mock('../user_profiles/use_suggest_users'); const MOCK_BUTTON_TEST_ID = 'mock-assignees-button'; @@ -30,9 +32,9 @@ const renderAssigneesPopover = ({ isPopoverOpen: boolean; }) => { const assignedProfiles = mockUserProfiles.filter((user) => assignedUserIds.includes(user.uid)); - (useGetUserProfiles as jest.Mock).mockReturnValue({ - loading: false, - userProfiles: assignedProfiles, + (useBulkGetUserProfiles as jest.Mock).mockReturnValue({ + isLoading: false, + data: assignedProfiles, }); return render( @@ -49,9 +51,13 @@ const renderAssigneesPopover = ({ describe('', () => { beforeEach(() => { jest.clearAllMocks(); + (useGetCurrentUser as jest.Mock).mockReturnValue({ + isLoading: false, + data: mockUserProfiles[0], + }); (useSuggestUsers as jest.Mock).mockReturnValue({ - loading: false, - userProfiles: mockUserProfiles, + isLoading: false, + data: mockUserProfiles, }); }); 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 index 45cbc200ea98c..8640b9c4a9c47 100644 --- 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 @@ -13,6 +13,14 @@ import { TEST_IDS } from './constants'; import { TestProviders } from '../../mock'; import type { AssigneesIdsSelection } from '../assignees/types'; +import { useGetCurrentUser } from '../user_profiles/use_get_current_user'; +import { useBulkGetUserProfiles } from '../user_profiles/use_bulk_get_user_profiles'; +import { useSuggestUsers } from '../user_profiles/use_suggest_users'; + +jest.mock('../user_profiles/use_get_current_user'); +jest.mock('../user_profiles/use_bulk_get_user_profiles'); +jest.mock('../user_profiles/use_suggest_users'); + const mockUserProfiles = [ { uid: 'user-id-1', @@ -33,14 +41,6 @@ const mockUserProfiles = [ data: {}, }, ]; -jest.mock('../user_profiles/use_suggest_users', () => { - return { - useSuggestUsers: () => ({ - loading: false, - userProfiles: mockUserProfiles, - }), - }; -}); const renderFilterByAssigneesPopover = ( alertAssignees: AssigneesIdsSelection[] = [], @@ -58,6 +58,18 @@ const renderFilterByAssigneesPopover = ( describe('', () => { beforeEach(() => { jest.clearAllMocks(); + (useGetCurrentUser 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, + }); }); it('should render closed popover component', () => { @@ -88,7 +100,7 @@ describe('', () => { expect(assigneesList).toHaveTextContent('user3@test.com'); }); - it('should call onUsersChange on clsing the popover', () => { + it('should call onUsersChange on closing the popover', () => { const onUsersChangeMock = jest.fn(); const { getByTestId, getByText } = renderFilterByAssigneesPopover([], onUsersChangeMock); 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 index 595ecaf5bd083..8c2381337b7cc 100644 --- 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 @@ -9,14 +9,16 @@ 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 { useGetUserProfiles } from '../../user_profiles/use_get_user_profiles'; +import { useGetCurrentUser } from '../../user_profiles/use_get_current_user'; +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_user_profiles'); +jest.mock('../../user_profiles/use_get_current_user'); +jest.mock('../../user_profiles/use_bulk_get_user_profiles'); jest.mock('../../user_profiles/use_suggest_users'); const mockUserProfiles = [ @@ -53,13 +55,17 @@ const mockAlertsWithAssignees = [ }, ]; -(useGetUserProfiles as jest.Mock).mockReturnValue({ - loading: false, - userProfiles: mockUserProfiles, +(useGetCurrentUser as jest.Mock).mockReturnValue({ + isLoading: false, + data: mockUserProfiles[0], +}); +(useBulkGetUserProfiles as jest.Mock).mockReturnValue({ + isLoading: false, + data: mockUserProfiles, }); (useSuggestUsers as jest.Mock).mockReturnValue({ - loading: false, - userProfiles: mockSuggestedUserProfiles, + isLoading: false, + data: mockSuggestedUserProfiles, }); const renderAssigneesMenu = ( 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 index 319be49096412..ae1a736a7dbb0 100644 --- 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 @@ -16,13 +16,13 @@ import type { import { useBulkAlertAssigneesItems } from './use_bulk_alert_assignees_items'; import { useSetAlertAssignees } from './use_set_alert_assignees'; import { useGetCurrentUser } from '../../user_profiles/use_get_current_user'; -import { useGetUserProfiles } from '../../user_profiles/use_get_user_profiles'; +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'; jest.mock('./use_set_alert_assignees'); jest.mock('../../user_profiles/use_get_current_user'); -jest.mock('../../user_profiles/use_get_user_profiles'); +jest.mock('../../user_profiles/use_bulk_get_user_profiles'); jest.mock('../../user_profiles/use_suggest_users'); const mockUserProfiles = [ @@ -55,16 +55,16 @@ describe('useBulkAlertAssigneesItems', () => { beforeEach(() => { (useSetAlertAssignees as jest.Mock).mockReturnValue(jest.fn()); (useGetCurrentUser as jest.Mock).mockReturnValue({ - loading: false, - userProfile: mockUserProfiles[0], + isLoading: false, + data: mockUserProfiles[0], }); - (useGetUserProfiles as jest.Mock).mockReturnValue({ - loading: false, - userProfiles: mockUserProfiles, + (useBulkGetUserProfiles as jest.Mock).mockReturnValue({ + isLoading: false, + data: mockUserProfiles, }); (useSuggestUsers as jest.Mock).mockReturnValue({ - loading: false, - userProfiles: mockUserProfiles, + isLoading: false, + data: mockUserProfiles, }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/user_profiles/use_get_user_profiles.test.tsx b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_bulk_get_user_profiles.test.tsx similarity index 76% rename from x-pack/plugins/security_solution/public/common/components/user_profiles/use_get_user_profiles.test.tsx rename to x-pack/plugins/security_solution/public/common/components/user_profiles/use_bulk_get_user_profiles.test.tsx index 1ade29a59f8e6..3861e6a6c8a67 100644 --- a/x-pack/plugins/security_solution/public/common/components/user_profiles/use_get_user_profiles.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_bulk_get_user_profiles.test.tsx @@ -9,16 +9,17 @@ import { renderHook } from '@testing-library/react-hooks'; import { securityMock } from '@kbn/security-plugin/public/mocks'; import { mockUserProfiles } from './mock'; -import { useGetUserProfiles } from './use_get_user_profiles'; +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('useGetUserProfiles hook', () => { +describe('useBulkGetUserProfiles hook', () => { let appToastsMock: jest.Mocked>; beforeEach(() => { jest.clearAllMocks(); @@ -37,14 +38,17 @@ describe('useGetUserProfiles hook', () => { it('returns an array of userProfiles', async () => { const userProfiles = useKibana().services.security.userProfiles; const spyOnUserProfiles = jest.spyOn(userProfiles, 'bulkGet'); - const assigneesIds = ['user1']; - const { result, waitForNextUpdate } = renderHook(() => useGetUserProfiles(assigneesIds)); + const assigneesIds = new Set(['user1']); + const { result, waitForNextUpdate } = renderHook( + () => useBulkGetUserProfiles({ uids: assigneesIds }), + { + wrapper: TestProviders, + } + ); await waitForNextUpdate(); expect(spyOnUserProfiles).toHaveBeenCalledTimes(1); - expect(result.current).toEqual({ - loading: false, - userProfiles: mockUserProfiles, - }); + expect(result.current.isLoading).toEqual(false); + expect(result.current.data).toEqual(mockUserProfiles); }); }); diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_bulk_get_user_profiles.ts b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_bulk_get_user_profiles.tsx similarity index 80% rename from x-pack/plugins/security_solution/public/common/hooks/use_bulk_get_user_profiles.ts rename to x-pack/plugins/security_solution/public/common/components/user_profiles/use_bulk_get_user_profiles.tsx index 60b3cfed8243c..b74e797162515 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/use_bulk_get_user_profiles.ts +++ b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_bulk_get_user_profiles.tsx @@ -9,7 +9,9 @@ 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 { useKibana } from '../../lib/kibana'; +import { useAppToasts } from '../../hooks/use_app_toasts'; +import { USER_PROFILES_FAILURE } from './translations'; export interface BulkGetUserProfilesArgs { security: SecurityPluginStart; @@ -28,6 +30,7 @@ export const bulkGetUserProfiles = async ({ export const useBulkGetUserProfiles = ({ uids }: { uids: Set }) => { const { security } = useKibana().services; + const { addError } = useAppToasts(); return useQuery( ['useBulkGetUserProfiles', ...uids], @@ -37,6 +40,9 @@ export const useBulkGetUserProfiles = ({ uids }: { uids: Set }) => { { 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.test.tsx b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_get_current_user.test.tsx index 924af4495c0fa..a2c74d5bff98c 100644 --- a/x-pack/plugins/security_solution/public/common/components/user_profiles/use_get_current_user.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_get_current_user.test.tsx @@ -14,6 +14,7 @@ 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'); @@ -37,13 +38,13 @@ describe('useGetCurrentUser hook', () => { it('returns current user', async () => { const userProfiles = useKibana().services.security.userProfiles; const spyOnUserProfiles = jest.spyOn(userProfiles, 'getCurrent'); - const { result, waitForNextUpdate } = renderHook(() => useGetCurrentUser()); + const { result, waitForNextUpdate } = renderHook(() => useGetCurrentUser(), { + wrapper: TestProviders, + }); await waitForNextUpdate(); expect(spyOnUserProfiles).toHaveBeenCalledTimes(1); - expect(result.current).toEqual({ - loading: false, - userProfile: mockCurrentUserProfile, - }); + 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.tsx b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_get_current_user.tsx index a46ae9a2a633f..99e4d229119b9 100644 --- a/x-pack/plugins/security_solution/public/common/components/user_profiles/use_get_current_user.tsx +++ b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_get_current_user.tsx @@ -5,46 +5,38 @@ * 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 { useEffect, useState } from 'react'; import { CURRENT_USER_PROFILE_FAILURE } from './translations'; import { useKibana } from '../../lib/kibana'; import { useAppToasts } from '../../hooks/use_app_toasts'; -interface GetCurrentUserReturn { - loading: boolean; - userProfile?: UserProfileWithAvatar; -} +export const getCurrentUser = async ({ + security, +}: { + security: SecurityPluginStart; +}): Promise => { + return security.userProfiles.getCurrent({ dataPath: 'avatar' }); +}; -export const useGetCurrentUser = (): GetCurrentUserReturn => { - const [loading, setLoading] = useState(false); - const [currentUser, setCurrentUser] = useState(undefined); +export const useGetCurrentUser = () => { + const { security } = useKibana().services; const { addError } = useAppToasts(); - const userProfiles = useKibana().services.security.userProfiles; - useEffect(() => { - // isMounted tracks if a component is mounted before changing state - let isMounted = true; - setLoading(true); - const fetchData = async () => { - try { - const profile = await userProfiles.getCurrent({ dataPath: 'avatar' }); - if (isMounted) { - setCurrentUser(profile); - } - } catch (error) { - addError(error.message, { title: CURRENT_USER_PROFILE_FAILURE }); - } - if (isMounted) { - setLoading(false); - } - }; - fetchData(); - return () => { - // updates to show component is unmounted - isMounted = false; - }; - }, [addError, userProfiles]); - return { loading, userProfile: currentUser }; + return useQuery( + ['useGetCurrentUser'], + async () => { + return getCurrentUser({ 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_get_user_profiles.tsx b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_get_user_profiles.tsx deleted file mode 100644 index 800baa3c0a20f..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/user_profiles/use_get_user_profiles.tsx +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; -import { useEffect, useState } from 'react'; - -import { USER_PROFILES_FAILURE } from './translations'; -import { useKibana } from '../../lib/kibana'; -import { useAppToasts } from '../../hooks/use_app_toasts'; - -interface GetUserProfilesReturn { - loading: boolean; - userProfiles: UserProfileWithAvatar[]; -} - -export const useGetUserProfiles = (userIds: string[]): GetUserProfilesReturn => { - const [loading, setLoading] = useState(false); - const [users, setUsers] = useState([]); - const { addError } = useAppToasts(); - const userProfiles = useKibana().services.security.userProfiles; - - useEffect(() => { - // isMounted tracks if a component is mounted before changing state - let isMounted = true; - setLoading(true); - const fetchData = async () => { - try { - const profiles = - userIds.length > 0 - ? await userProfiles.bulkGet({ - uids: new Set(userIds), - dataPath: 'avatar', - }) - : []; - if (isMounted) { - setUsers(profiles); - } - } catch (error) { - addError(error.message, { title: USER_PROFILES_FAILURE }); - } - if (isMounted) { - setLoading(false); - } - }; - fetchData(); - return () => { - // updates to show component is unmounted - isMounted = false; - }; - }, [addError, userProfiles, userIds]); - return { loading, userProfiles: users }; -}; 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 index a7f08a89fab1b..2cb727942ed57 100644 --- 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 @@ -12,6 +12,7 @@ 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'); @@ -26,12 +27,12 @@ describe('useSuggestUsers hook', () => { it('returns an array of userProfiles', async () => { const spyOnUserProfiles = jest.spyOn(api, 'suggestUsers'); - const { result, waitForNextUpdate } = renderHook(() => useSuggestUsers('')); + const { result, waitForNextUpdate } = renderHook(() => useSuggestUsers({ searchTerm: '' }), { + wrapper: TestProviders, + }); await waitForNextUpdate(); expect(spyOnUserProfiles).toHaveBeenCalledTimes(1); - expect(result.current).toEqual({ - loading: false, - userProfiles: mockUserProfiles, - }); + 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 index b01f786de9393..a8a2338e51e9d 100644 --- 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 @@ -5,45 +5,40 @@ * 2.0. */ +import { useQuery } from '@tanstack/react-query'; + import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; -import { useEffect, useState } from 'react'; import { suggestUsers } from './api'; import { USER_PROFILES_FAILURE } from './translations'; import { useAppToasts } from '../../hooks/use_app_toasts'; -interface SuggestUsersReturn { - loading: boolean; - userProfiles: UserProfileWithAvatar[]; +export interface SuggestUserProfilesArgs { + searchTerm: string; } -export const useSuggestUsers = (searchTerm: string): SuggestUsersReturn => { - const [loading, setLoading] = useState(false); - const [users, setUsers] = useState([]); +export const bulkGetUserProfiles = async ({ + searchTerm, +}: { + searchTerm: string; +}): Promise => { + return suggestUsers({ searchTerm }); +}; + +export const useSuggestUsers = ({ searchTerm }: { searchTerm: string }) => { const { addError } = useAppToasts(); - useEffect(() => { - // isMounted tracks if a component is mounted before changing state - let isMounted = true; - setLoading(true); - const fetchData = async () => { - try { - const usersResponse = await suggestUsers({ searchTerm }); - if (isMounted) { - setUsers(usersResponse); - } - } catch (error) { - addError(error.message, { title: USER_PROFILES_FAILURE }); - } - if (isMounted) { - setLoading(false); - } - }; - fetchData(); - return () => { - // updates to show component is unmounted - isMounted = false; - }; - }, [addError, searchTerm]); - return { loading, userProfiles: users }; + 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/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 index 444e1dadac90d..cf6fdf6df5251 100644 --- 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 @@ -17,13 +17,13 @@ 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 { useGetCurrentUser } from '../../../../common/components/user_profiles/use_get_current_user'; -import { useGetUserProfiles } from '../../../../common/components/user_profiles/use_get_user_profiles'; +import { useBulkGetUserProfiles } from '../../../../common/components/user_profiles/use_bulk_get_user_profiles'; import { useSuggestUsers } from '../../../../common/components/user_profiles/use_suggest_users'; 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'); -jest.mock('../../../../common/components/user_profiles/use_get_user_profiles'); +jest.mock('../../../../common/components/user_profiles/use_bulk_get_user_profiles'); jest.mock('../../../../common/components/user_profiles/use_suggest_users'); const mockUserProfiles = [ @@ -164,16 +164,16 @@ describe('useAlertAssigneesActions', () => { it('should render the nested panel', async () => { (useSetAlertAssignees as jest.Mock).mockReturnValue(jest.fn()); (useGetCurrentUser as jest.Mock).mockReturnValue({ - loading: false, - userProfile: mockUserProfiles[0], + isLoading: false, + data: mockUserProfiles[0], }); - (useGetUserProfiles as jest.Mock).mockReturnValue({ - loading: false, - userProfiles: mockUserProfiles, + (useBulkGetUserProfiles as jest.Mock).mockReturnValue({ + isLoading: false, + data: mockUserProfiles, }); (useSuggestUsers as jest.Mock).mockReturnValue({ - loading: false, - userProfiles: mockUserProfiles, + isLoading: false, + data: mockUserProfiles, }); const { result } = renderHook(() => useAlertAssigneesActions(defaultProps), { 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 index f7609b4f09c87..ebd8df15d92ea 100644 --- 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 @@ -8,7 +8,7 @@ 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/hooks/use_bulk_get_user_profiles'; +import { useBulkGetUserProfiles } from '../../../common/components/user_profiles/use_bulk_get_user_profiles'; export interface RenderCellValueContext { profiles: UserProfileWithAvatar[] | undefined; 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 index 5f95df86e922a..df19a7b3b3252 100644 --- 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 @@ -11,7 +11,8 @@ import { render } from '@testing-library/react'; import { ASSIGNEES_ADD_BUTTON_TEST_ID, ASSIGNEES_TITLE_TEST_ID } from './test_ids'; import { Assignees } from './assignees'; -import { useGetUserProfiles } from '../../../../common/components/user_profiles/use_get_user_profiles'; +import { useGetCurrentUser } from '../../../../common/components/user_profiles/use_get_current_user'; +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'; @@ -23,7 +24,8 @@ import { USER_AVATAR_ITEM_TEST_ID, } from '../../../../common/components/user_profiles/test_ids'; -jest.mock('../../../../common/components/user_profiles/use_get_user_profiles'); +jest.mock('../../../../common/components/user_profiles/use_get_current_user'); +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'); @@ -39,9 +41,9 @@ const renderAssignees = ( onAssigneesUpdated = jest.fn() ) => { const assignedProfiles = mockUserProfiles.filter((user) => alertAssignees.includes(user.uid)); - (useGetUserProfiles as jest.Mock).mockReturnValue({ - loading: false, - userProfiles: assignedProfiles, + (useBulkGetUserProfiles as jest.Mock).mockReturnValue({ + isLoading: false, + data: assignedProfiles, }); return render( @@ -59,9 +61,13 @@ describe('', () => { beforeEach(() => { jest.clearAllMocks(); + (useGetCurrentUser as jest.Mock).mockReturnValue({ + isLoading: false, + data: mockUserProfiles[0], + }); (useSuggestUsers as jest.Mock).mockReturnValue({ - loading: false, - userProfiles: mockUserProfiles, + isLoading: false, + data: mockUserProfiles, }); setAlertAssigneesMock = jest.fn().mockReturnValue(Promise.resolve()); @@ -101,9 +107,9 @@ describe('', () => { it('should call assignees update functionality with the right arguments', () => { const assignedProfiles = [mockUserProfiles[0], mockUserProfiles[1]]; - (useGetUserProfiles as jest.Mock).mockReturnValue({ - loading: false, - userProfiles: assignedProfiles, + (useBulkGetUserProfiles as jest.Mock).mockReturnValue({ + isLoading: false, + data: assignedProfiles, }); const assignees = assignedProfiles.map((assignee) => assignee.uid); 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 index 74ddc65210300..7b4923159fe74 100644 --- 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 @@ -7,13 +7,13 @@ import { noop } from 'lodash'; import type { FC } from 'react'; -import React, { memo, useCallback, useState } 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 { useGetUserProfiles } from '../../../../common/components/user_profiles/use_get_user_profiles'; +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'; @@ -64,7 +64,9 @@ export interface AssigneesProps { export const Assignees: FC = memo( ({ eventId, assignedUserIds, onAssigneesUpdated }) => { const setAlertAssignees = useSetAlertAssignees(); - const { userProfiles: assignedUsers } = useGetUserProfiles(assignedUserIds); + + const uids = useMemo(() => new Set(assignedUserIds), [assignedUserIds]); + const { data: assignedUsers } = useBulkGetUserProfiles({ uids }); const [isPopoverOpen, setIsPopoverOpen] = useState(false); @@ -107,9 +109,11 @@ export const Assignees: FC = memo( - - - + {assignedUsers && ( + + + + )} { +jest.mock('../../../../common/components/user_profiles/use_bulk_get_user_profiles', () => { return { - useGetUserProfiles: jest.fn().mockReturnValue({ loading: false, userProfiles: [] }), + useBulkGetUserProfiles: jest.fn().mockReturnValue({ isLoading: false, data: [] }), }; }); jest.mock('../../../../common/components/user_profiles/use_suggest_users', () => { return { - useSuggestUsers: jest.fn().mockReturnValue({ loading: false, userProfiles: [] }), + useSuggestUsers: jest.fn().mockReturnValue({ isLoading: false, data: [] }), }; }); 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 1afc6238887dc..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,15 +30,15 @@ jest.mock('../../../common/containers/use_search_strategy', () => ({ useSearchStrategy: jest.fn(), })); -jest.mock('../../../common/components/user_profiles/use_get_user_profiles', () => { +jest.mock('../../../common/components/user_profiles/use_bulk_get_user_profiles', () => { return { - useGetUserProfiles: jest.fn().mockReturnValue({ loading: false, userProfiles: [] }), + useBulkGetUserProfiles: jest.fn().mockReturnValue({ isLoading: false, data: [] }), }; }); jest.mock('../../../common/components/user_profiles/use_suggest_users', () => { return { - useSuggestUsers: jest.fn().mockReturnValue({ loading: false, userProfiles: [] }), + useSuggestUsers: jest.fn().mockReturnValue({ isLoading: false, data: [] }), }; }); From 7267005b32f9dc00b30303b129110624083033a1 Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Fri, 3 Nov 2023 22:21:50 +0100 Subject: [PATCH 16/53] Update schema version from 8.11 to 8.12 (#170576) ## Summary We pushed feature release from 8.11 to 8.12, thus need to update kibana version in which new schema will be introduced. --- .../model/alerts/{8.11.0 => 8.12.0}/index.ts | 34 +++++++++---------- .../detection_engine/model/alerts/index.ts | 32 ++++++++--------- 2 files changed, 33 insertions(+), 33 deletions(-) rename x-pack/plugins/security_solution/common/api/detection_engine/model/alerts/{8.11.0 => 8.12.0}/index.ts (57%) diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/model/alerts/8.11.0/index.ts b/x-pack/plugins/security_solution/common/api/detection_engine/model/alerts/8.12.0/index.ts similarity index 57% rename from x-pack/plugins/security_solution/common/api/detection_engine/model/alerts/8.11.0/index.ts rename to x-pack/plugins/security_solution/common/api/detection_engine/model/alerts/8.12.0/index.ts index d9b682027bfc7..da97667035a66 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/model/alerts/8.11.0/index.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/model/alerts/8.12.0/index.ts @@ -15,42 +15,42 @@ import type { NewTermsFields890, } from '../8.9.0'; -/* DO NOT MODIFY THIS SCHEMA TO ADD NEW FIELDS. These types represent the alerts that shipped in 8.11.0. -Any changes to these types should be bug fixes so the types more accurately represent the alerts from 8.11.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 Ancestor8110 }; +export type { Ancestor890 as Ancestor8120 }; -export interface BaseFields8110 extends BaseFields890 { +export interface BaseFields8120 extends BaseFields890 { [ALERT_WORKFLOW_ASSIGNEE_IDS]: string[] | undefined; } -export interface WrappedFields8110 { +export interface WrappedFields8120 { _id: string; _index: string; _source: T; } -export type GenericAlert8110 = AlertWithCommonFields800; +export type GenericAlert8120 = AlertWithCommonFields800; -export type EqlShellFields8110 = EqlShellFields890 & BaseFields8110; +export type EqlShellFields8120 = EqlShellFields890 & BaseFields8120; -export type EqlBuildingBlockFields8110 = EqlBuildingBlockFields890 & BaseFields8110; +export type EqlBuildingBlockFields8120 = EqlBuildingBlockFields890 & BaseFields8120; -export type NewTermsFields8110 = NewTermsFields890 & BaseFields8110; +export type NewTermsFields8120 = NewTermsFields890 & BaseFields8120; -export type NewTermsAlert8110 = NewTermsFields890 & BaseFields8110; +export type NewTermsAlert8120 = NewTermsFields890 & BaseFields8120; -export type EqlBuildingBlockAlert8110 = AlertWithCommonFields800; +export type EqlBuildingBlockAlert8120 = AlertWithCommonFields800; -export type EqlShellAlert8110 = AlertWithCommonFields800; +export type EqlShellAlert8120 = AlertWithCommonFields800; -export type DetectionAlert8110 = - | GenericAlert8110 - | EqlShellAlert8110 - | EqlBuildingBlockAlert8110 - | NewTermsAlert8110; +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 a56bd2068549a..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 @@ -13,14 +13,14 @@ import type { DetectionAlert870 } from './8.7.0'; import type { DetectionAlert880 } from './8.8.0'; import type { DetectionAlert890 } from './8.9.0'; import type { - Ancestor8110, - BaseFields8110, - DetectionAlert8110, - EqlBuildingBlockFields8110, - EqlShellFields8110, - NewTermsFields8110, - WrappedFields8110, -} from './8.11.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 @@ -31,14 +31,14 @@ export type DetectionAlert = | DetectionAlert870 | DetectionAlert880 | DetectionAlert890 - | DetectionAlert8110; + | DetectionAlert8120; export type { - Ancestor8110 as AncestorLatest, - BaseFields8110 as BaseFieldsLatest, - DetectionAlert8110 as DetectionAlertLatest, - WrappedFields8110 as WrappedFieldsLatest, - EqlBuildingBlockFields8110 as EqlBuildingBlockFieldsLatest, - EqlShellFields8110 as EqlShellFieldsLatest, - NewTermsFields8110 as NewTermsFieldsLatest, + Ancestor8120 as AncestorLatest, + BaseFields8120 as BaseFieldsLatest, + DetectionAlert8120 as DetectionAlertLatest, + WrappedFields8120 as WrappedFieldsLatest, + EqlBuildingBlockFields8120 as EqlBuildingBlockFieldsLatest, + EqlShellFields8120 as EqlShellFieldsLatest, + NewTermsFields8120 as NewTermsFieldsLatest, }; From 5d914a4d965297e126b11ed6c8803ab54d048df0 Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Mon, 6 Nov 2023 14:29:45 +0100 Subject: [PATCH 17/53] Revert "Make alerts table row to fit user profile avatars by default (#170505)" This reverts commit 89ec60ac0a23c91870893a06ea13ac37cea02776. --- .../detections/components/alerts_table/index.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx index 9dd627cec4444..4be4595e19afe 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx @@ -197,11 +197,14 @@ export const AlertsTableComponent: FC = ({ [isEventRenderedView] ); - const rowHeightsOptions: EuiDataGridRowHeightsOptions = useMemo(() => { - return { - defaultHeight: 'auto', - }; - }, []); + const rowHeightsOptions: EuiDataGridRowHeightsOptions | undefined = useMemo(() => { + if (isEventRenderedView) { + return { + defaultHeight: 'auto', + }; + } + return undefined; + }, [isEventRenderedView]); const alertColumns = useMemo( () => (columns.length ? columns : getColumns(license)), From e9d2085cfce043fdddf9dd5352bf7900d743f4d6 Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Tue, 7 Nov 2023 17:53:21 +0100 Subject: [PATCH 18/53] Fix broken tests Followup to [Security Solution] Removing cleanKibana method from Cypress #170636 (https://github.com/elastic/kibana/pull/170636) --- .../detection_alerts/alert_assignees.cy.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/alert_assignees.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/alert_assignees.cy.ts index 0427c17ebb213..0073b4a5fabcc 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/alert_assignees.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/alert_assignees.cy.ts @@ -15,7 +15,7 @@ import { updateAlertAssignees, } from '../../../tasks/alerts'; import { createRule } from '../../../tasks/api_calls/rules'; -import { cleanKibana, deleteAlertsAndRules } from '../../../tasks/common'; +import { deleteAlertsAndRules } from '../../../tasks/common'; import { login } from '../../../tasks/login'; import { visitWithTimeRange } from '../../../tasks/navigation'; import { ALERTS_URL } from '../../../urls/navigation'; @@ -28,10 +28,6 @@ import { } from '../../../tasks/alert_assignees'; describe('Alert assigning', { tags: ['@ess', '@serverless'] }, () => { - before(() => { - cleanKibana(); - }); - beforeEach(() => { login(); deleteAlertsAndRules(); From a339a3e36c47c572fea24a8ee9644a22434abcf4 Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Wed, 8 Nov 2023 15:37:19 +0100 Subject: [PATCH 19/53] Review feedback --- .../use_assignees_actions.test.tsx | 113 ++++++++++++++++++ .../use_assignees_actions.tsx | 2 +- .../right/components/assignees.tsx | 2 +- 3 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_assignees_actions.test.tsx diff --git a/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_assignees_actions.test.tsx b/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_assignees_actions.test.tsx new file mode 100644 index 0000000000000..5d0ecbedeec92 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_assignees_actions.test.tsx @@ -0,0 +1,113 @@ +/* + * 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 { renderHook } from '@testing-library/react-hooks'; +import type { UseAssigneesActionItemsProps } from './use_assignees_actions'; +import { useAssigneesActionItems } from './use_assignees_actions'; +import { useSetAlertAssignees } from '../../../common/components/toolbar/bulk_actions/use_set_alert_assignees'; +import { useGetCurrentUser } from '../../../common/components/user_profiles/use_get_current_user'; +import { useBulkGetUserProfiles } from '../../../common/components/user_profiles/use_bulk_get_user_profiles'; +import { useSuggestUsers } from '../../../common/components/user_profiles/use_suggest_users'; +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'; + +jest.mock('../../../common/components/toolbar/bulk_actions/use_set_alert_assignees'); +jest.mock('../../../common/components/user_profiles/use_get_current_user'); +jest.mock('../../../common/components/user_profiles/use_bulk_get_user_profiles'); +jest.mock('../../../common/components/user_profiles/use_suggest_users'); + +const mockUserProfiles = [ + { uid: 'user-id-1', enabled: true, user: { username: 'fakeUser1' }, data: {} }, + { uid: 'user-id-2', enabled: true, user: { username: 'fakeUser2' }, data: {} }, +]; + +const defaultProps: UseAssigneesActionItemsProps = { + refetch: () => {}, +}; + +describe('useAssigneesActionItems', () => { + beforeEach(() => { + (useSetAlertAssignees as jest.Mock).mockReturnValue(jest.fn()); + (useGetCurrentUser 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, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return two alert assignees action items and one panel', () => { + const { result } = renderHook(() => useAssigneesActionItems(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( + 'bulk-alert-assignees-remove-all-action' + ); + expect(result.current.alertAssigneesPanels[0]['data-test-subj']).toEqual( + 'alert-assignees-context-menu-panel' + ); + }); + + it('should call setAlertAssignees returned by useSetAlertAssignees with the correct parameters', () => { + const mockSetAlertAssignees = jest.fn(); + (useSetAlertAssignees as jest.Mock).mockReturnValue(mockSetAlertAssignees); + const { result } = renderHook(() => useAssigneesActionItems(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 refreshMock = jest.fn(); + const setAlertLoadingMock = jest.fn(); + ( + result.current.alertAssigneesItems[1] as unknown as { onClick: BulkActionsConfig['onClick'] } + ).onClick?.(items, true, setAlertLoadingMock, jest.fn(), refreshMock); + + expect(mockSetAlertAssignees).toHaveBeenCalled(); + expect(mockSetAlertAssignees).toHaveBeenCalledWith( + { assignees_to_add: [], assignees_to_remove: ['user1', 'user2', 'user3'] }, + ['alert1', 'alert2', 'alert3'], + refreshMock, + setAlertLoadingMock + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_assignees_actions.tsx b/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_assignees_actions.tsx index 526ea16b2f050..f2f6e21bd16d4 100644 --- a/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_assignees_actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_assignees_actions.tsx @@ -14,7 +14,7 @@ import { useSetAlertAssignees } from '../../../common/components/toolbar/bulk_ac import { useBulkAlertAssigneesItems } from '../../../common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items'; import * as i18n from '../translations'; -interface UseAssigneesActionItemsProps { +export interface UseAssigneesActionItemsProps { refetch?: () => void; } 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 index 7b4923159fe74..caff397abb686 100644 --- 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 @@ -98,7 +98,7 @@ export const Assignees: FC = memo( ); return ( - +

From 221723d338bee90e1681317b595b93bacbb82e74 Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Wed, 8 Nov 2023 18:59:47 +0100 Subject: [PATCH 20/53] Fix broken test --- .../components/side_panel/__snapshots__/index.test.tsx.snap | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 8756f79b422c5..4bc7ed72330a8 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 @@ -79,7 +79,7 @@ Array [ class="euiFlexItem emotion-euiFlexItem-growZero" >
Date: Fri, 10 Nov 2023 16:51:34 +0100 Subject: [PATCH 21/53] [Security Solution][Detections] Disable alert assignees updates for VIEWER role (#8019) --- .../use_bulk_alert_assignees_items.test.tsx | 13 +++ .../use_bulk_alert_assignees_items.tsx | 51 +++++++----- .../use_assignees_actions.test.tsx | 14 ++++ .../use_assignees_actions.tsx | 79 ++++++++++--------- .../right/components/assignees.test.tsx | 14 ++++ .../right/components/assignees.tsx | 45 ++++++----- 6 files changed, 141 insertions(+), 75 deletions(-) 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 index ae1a736a7dbb0..02e259e27a099 100644 --- 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 @@ -19,11 +19,13 @@ import { useGetCurrentUser } from '../../user_profiles/use_get_current_user'; 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'; jest.mock('./use_set_alert_assignees'); jest.mock('../../user_profiles/use_get_current_user'); 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'); const mockUserProfiles = [ { uid: 'user-id-1', enabled: true, user: { username: 'fakeUser1' }, data: {} }, @@ -66,6 +68,7 @@ describe('useBulkAlertAssigneesItems', () => { isLoading: false, data: mockUserProfiles, }); + (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasIndexWrite: true }); }); afterEach(() => { @@ -117,4 +120,14 @@ describe('useBulkAlertAssigneesItems', () => { }); expect(mockSetAlertAssignees).toHaveBeenCalled(); }); + + 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); + }); }); 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 index bdb9de99715ea..9e2ff728c913e 100644 --- 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 @@ -8,6 +8,7 @@ import { EuiFlexGroup, EuiIconTip, EuiFlexItem } from '@elastic/eui'; import type { RenderContentPanelProps } from '@kbn/triggers-actions-ui-plugin/public/types'; import React, { useCallback, useMemo } from 'react'; +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'; @@ -26,6 +27,7 @@ export interface UseBulkAlertAssigneesPanel { } export const useBulkAlertAssigneesItems = ({ refetch }: UseBulkAlertAssigneesItemsProps) => { + const { hasIndexWrite } = useAlertsPrivileges(); const setAlertAssignees = useSetAlertAssignees(); const handleOnAlertAssigneesSubmit = useCallback( async (assignees, ids, onSuccess, setIsLoading) => { @@ -36,16 +38,22 @@ export const useBulkAlertAssigneesItems = ({ refetch }: UseBulkAlertAssigneesIte [setAlertAssignees] ); - const alertAssigneesItems = [ - { - 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, - }, - ]; + const alertAssigneesItems = useMemo( + () => + hasIndexWrite + ? [ + { + 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, + }, + ] + : [], + [hasIndexWrite] + ); const TitleContent = useMemo( () => ( @@ -84,16 +92,19 @@ export const useBulkAlertAssigneesItems = ({ refetch }: UseBulkAlertAssigneesIte ); const alertAssigneesPanels: UseBulkAlertAssigneesPanel[] = useMemo( - () => [ - { - id: 2, - title: TitleContent, - 'data-test-subj': 'alert-assignees-context-menu-panel', - renderContent, - width: ASSIGNEES_PANEL_WIDTH, - }, - ], - [TitleContent, renderContent] + () => + hasIndexWrite + ? [ + { + id: 2, + title: TitleContent, + 'data-test-subj': 'alert-assignees-context-menu-panel', + renderContent, + width: ASSIGNEES_PANEL_WIDTH, + }, + ] + : [], + [TitleContent, hasIndexWrite, renderContent] ); return { diff --git a/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_assignees_actions.test.tsx b/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_assignees_actions.test.tsx index 5d0ecbedeec92..4a3cd070d7ef2 100644 --- a/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_assignees_actions.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_assignees_actions.test.tsx @@ -16,11 +16,13 @@ import { useBulkGetUserProfiles } from '../../../common/components/user_profiles import { useSuggestUsers } from '../../../common/components/user_profiles/use_suggest_users'; 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 { useAlertsPrivileges } from '../../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'); jest.mock('../../../common/components/user_profiles/use_bulk_get_user_profiles'); jest.mock('../../../common/components/user_profiles/use_suggest_users'); +jest.mock('../../containers/detection_engine/alerts/use_alerts_privileges'); const mockUserProfiles = [ { uid: 'user-id-1', enabled: true, user: { username: 'fakeUser1' }, data: {} }, @@ -46,6 +48,7 @@ describe('useAssigneesActionItems', () => { isLoading: false, data: mockUserProfiles, }); + (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasIndexWrite: true }); }); afterEach(() => { @@ -110,4 +113,15 @@ describe('useAssigneesActionItems', () => { setAlertLoadingMock ); }); + + it('should return 0 items for the VIEWER role', () => { + (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasIndexWrite: false }); + + const { result } = renderHook(() => useAssigneesActionItems(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/detections/hooks/trigger_actions_alert_table/use_assignees_actions.tsx b/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_assignees_actions.tsx index f2f6e21bd16d4..26fa718ead4e1 100644 --- a/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_assignees_actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_assignees_actions.tsx @@ -6,6 +6,7 @@ */ import { union } from 'lodash'; +import { useCallback, useMemo } from 'react'; import type { BulkActionsConfig } from '@kbn/triggers-actions-ui-plugin/public/types'; import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils'; @@ -13,12 +14,14 @@ import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils'; import { useSetAlertAssignees } from '../../../common/components/toolbar/bulk_actions/use_set_alert_assignees'; import { useBulkAlertAssigneesItems } from '../../../common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items'; import * as i18n from '../translations'; +import { useAlertsPrivileges } from '../../containers/detection_engine/alerts/use_alerts_privileges'; export interface UseAssigneesActionItemsProps { refetch?: () => void; } export const useAssigneesActionItems = ({ refetch }: UseAssigneesActionItemsProps) => { + const { hasIndexWrite } = useAlertsPrivileges(); const setAlertAssignees = useSetAlertAssignees(); const { alertAssigneesItems: basicAssigneesItems, alertAssigneesPanels } = @@ -26,43 +29,47 @@ export const useAssigneesActionItems = ({ refetch }: UseAssigneesActionItemsProp refetch, }); - const onActionClick: BulkActionsConfig['onClick'] = async ( - items, - isSelectAllChecked, - setAlertLoading, - clearSelection, - refresh - ) => { - const ids: string[] | undefined = 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 = { - assignees_to_add: [], - assignees_to_remove: assignedUserIds, - }; - if (setAlertAssignees) { - await setAlertAssignees(assignees, ids, refresh, setAlertLoading); - } - }; + const onActionClick = useCallback['onClick']>( + async (items, isSelectAllChecked, setAlertLoading, clearSelection, refresh) => { + const ids: string[] | undefined = 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 = { + assignees_to_add: [], + assignees_to_remove: assignedUserIds, + }; + if (setAlertAssignees) { + await setAlertAssignees(assignees, ids, refresh, setAlertLoading); + } + }, + [setAlertAssignees] + ); - const alertAssigneesItems = [ - ...basicAssigneesItems, - ...[ - { - label: i18n.BULK_REMOVE_ASSIGNEES_CONTEXT_MENU_TITLE, - key: 'bulk-alert-assignees-remove-all-action', - 'data-test-subj': 'bulk-alert-assignees-remove-all-action', - disableOnQuery: false, - onClick: onActionClick, - }, - ], - ]; + const alertAssigneesItems = useMemo( + () => + hasIndexWrite + ? [ + ...basicAssigneesItems, + ...[ + { + label: i18n.BULK_REMOVE_ASSIGNEES_CONTEXT_MENU_TITLE, + key: 'bulk-alert-assignees-remove-all-action', + 'data-test-subj': 'bulk-alert-assignees-remove-all-action', + disableOnQuery: false, + onClick: onActionClick, + }, + ], + ] + : [], + [basicAssigneesItems, hasIndexWrite, onActionClick] + ); return { alertAssigneesItems, 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 index df19a7b3b3252..0a507b6bd3303 100644 --- 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 @@ -23,11 +23,13 @@ import { 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'); 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('../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'); const mockUserProfiles = [ { uid: 'user-id-1', enabled: true, user: { username: 'user1', full_name: 'User 1' }, data: {} }, @@ -69,6 +71,7 @@ describe('', () => { isLoading: false, data: mockUserProfiles, }); + (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasIndexWrite: true }); setAlertAssigneesMock = jest.fn().mockReturnValue(Promise.resolve()); (useSetAlertAssignees as jest.Mock).mockReturnValue(setAlertAssigneesMock); @@ -80,6 +83,7 @@ describe('', () => { 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', () => { @@ -133,4 +137,14 @@ describe('', () => { 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(); + }); }); 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 index caff397abb686..ab2d45dea440c 100644 --- 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 @@ -13,6 +13,7 @@ import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiTitle, EuiToolTip } from ' import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +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'; @@ -21,24 +22,27 @@ import { UsersAvatarsPanel } from '../../../../common/components/user_profiles/u import { useSetAlertAssignees } from '../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees'; import { ASSIGNEES_ADD_BUTTON_TEST_ID, ASSIGNEES_TITLE_TEST_ID } from './test_ids'; -const UpdateAssigneesButton: FC<{ togglePopover: () => void }> = memo(({ togglePopover }) => ( - - - -)); +const UpdateAssigneesButton: FC<{ togglePopover: () => void; isDisabled: boolean }> = memo( + ({ togglePopover, isDisabled }) => ( + + + + ) +); UpdateAssigneesButton.displayName = 'UpdateAssigneesButton'; export interface AssigneesProps { @@ -63,6 +67,7 @@ export interface AssigneesProps { */ export const Assignees: FC = memo( ({ eventId, assignedUserIds, onAssigneesUpdated }) => { + const { hasIndexWrite } = useAlertsPrivileges(); const setAlertAssignees = useSetAlertAssignees(); const uids = useMemo(() => new Set(assignedUserIds), [assignedUserIds]); @@ -117,7 +122,9 @@ export const Assignees: FC = memo( } + button={ + + } isPopoverOpen={isPopoverOpen} closePopover={togglePopover} onAssigneesApply={onAssigneesApply} From 94c3bb5ac922d74c5f7d8c579fd96003b74f1772 Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Fri, 10 Nov 2023 18:00:48 +0100 Subject: [PATCH 22/53] Fix broken tests --- .../components/side_panel/__snapshots__/index.test.tsx.snap | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 4bc7ed72330a8..626f41b79507b 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 @@ -113,8 +113,9 @@ Array [ >

- Assigned: + Assignees:
{ await cases.common.setSearchTextInAssigneesPopover('case'); await cases.common.selectFirstRowInAssigneesPopover(); - await (await find.byButtonText('Remove all assignees')).click(); + await (await find.byButtonText('Unassign alert')).click(); await cases.singleCase.closeAssigneesPopover(); await testSubjects.missingOrFail('user-profile-assigned-user-abc-remove-group'); }); From 28f3ad30758e7f24b79b870a96c44bcf555df3c2 Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Wed, 22 Nov 2023 13:37:27 +0100 Subject: [PATCH 32/53] Fix broken tests --- .../signals/set_alert_assignees_route.test.ts | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) 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 index 58853a4bfb1ec..c8b419a6fdbfb 100644 --- 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 @@ -68,27 +68,28 @@ describe('setAlertAssigneesRoute', () => { }); }); - test('returns 400 if no alert ids are provided', async () => { + 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']), }); - context.core.elasticsearch.client.asCurrentUser.updateByQuery.mockResponse( - getSuccessfulSignalUpdateResponse() - ); - - const response = await server.inject(request, requestContextMock.convertContext(context)); + const result = server.validate(request); - context.core.elasticsearch.client.asCurrentUser.updateByQuery.mockRejectedValue( - new Error('Test error') - ); + expect(result.badRequest).toHaveBeenCalledWith('Invalid value "[]" supplied to "ids"'); + }); - expect(response.body).toEqual({ - message: [`No alert ids were provided`], - status_code: 400, + 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('Invalid value "" supplied to "ids"'); }); }); From cb5312b122bfa6647f395e22cb3234894b7843d9 Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Wed, 22 Nov 2023 13:50:41 +0100 Subject: [PATCH 33/53] Disable "Unassign alert" on query all alerts to align with other context menu items --- .../toolbar/bulk_actions/use_bulk_alert_assignees_items.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index fb7961503b723..1f5d7b0cab233 100644 --- 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 @@ -92,7 +92,7 @@ export const useBulkAlertAssigneesItems = ({ '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: false, + disableOnQuery: true, onClick: onRemoveAllAssignees, }, ] From f6faef4d7d50966c7904541f310ae6c9021882ab Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Wed, 22 Nov 2023 18:31:07 +0100 Subject: [PATCH 34/53] [Security Solution][Detections] Write a test plan for Alert User Assignment (#171306) (#171337) ## Summary Addresses https://github.com/elastic/kibana/issues/171306 Test plans for the Alert User Assignment feature. --- .../alerts/user_assignment.md | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 x-pack/plugins/security_solution/docs/testing/test_plans/detection_response/alerts/user_assignment.md 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..de92941a45523 --- /dev/null +++ b/x-pack/plugins/security_solution/docs/testing/test_plans/detection_response/alerts/user_assignment.md @@ -0,0 +1,217 @@ +# 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 +- The feature is available under the Basic license +- 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 +``` From f6313e34da9491ecdf0132cba67bd1b71e4ab12d Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Wed, 22 Nov 2023 18:34:32 +0100 Subject: [PATCH 35/53] Review feedback: Better typings in `triggers_actions_ui` --- .../public/application/alert_table_config_registry.ts | 4 ++-- x-pack/plugins/triggers_actions_ui/public/types.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/alert_table_config_registry.ts b/x-pack/plugins/triggers_actions_ui/public/application/alert_table_config_registry.ts index 308bd8668b7c1..62dc289730a0c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/alert_table_config_registry.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/alert_table_config_registry.ts @@ -15,7 +15,7 @@ import { export class AlertTableConfigRegistry { private readonly objectTypes: Map< string, - AlertsTableConfigurationRegistry | AlertsTableConfigurationRegistryWithActions + AlertsTableConfigurationRegistry | AlertsTableConfigurationRegistryWithActions > = new Map(); /** @@ -28,7 +28,7 @@ export class AlertTableConfigRegistry { /** * Registers an object type to the type registry */ - public register(objectType: AlertsTableConfigurationRegistry) { + public register(objectType: AlertsTableConfigurationRegistry) { if (this.has(objectType.id)) { throw new Error( i18n.translate( diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index 1172c5a3c6db5..14be6fea570cb 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -583,7 +583,7 @@ export type GetRenderCellValue = ({ context?: T; }) => (props: unknown) => React.ReactNode; -export type PreFetchPageContext = ({ +export type PreFetchPageContext = ({ alerts, columns, }: { @@ -686,7 +686,7 @@ export interface UseFieldBrowserOptionsArgs { export type UseFieldBrowserOptions = (args: UseFieldBrowserOptionsArgs) => FieldBrowserOptions; -export interface AlertsTableConfigurationRegistry { +export interface AlertsTableConfigurationRegistry { id: string; cases?: { featureId: string; @@ -700,7 +700,7 @@ export interface AlertsTableConfigurationRegistry { footer: AlertTableFlyoutComponent; }; sort?: SortCombinations[]; - getRenderCellValue?: GetRenderCellValue; + getRenderCellValue?: GetRenderCellValue; useActionsColumn?: UseActionsColumnRegistry; useBulkActions?: UseBulkActionsRegistry; useCellActions?: UseCellActions; @@ -709,7 +709,7 @@ export interface AlertsTableConfigurationRegistry { }; useFieldBrowserOptions?: UseFieldBrowserOptions; showInspectButton?: boolean; - useFetchPageContext?: PreFetchPageContext; + useFetchPageContext?: PreFetchPageContext; } export interface AlertsTableConfigurationRegistryWithActions From 695744bc931b833461b1026769a4d167e622f79a Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Mon, 20 Nov 2023 19:18:33 +0100 Subject: [PATCH 36/53] [Security Solution][Detections] Add test coverage for Alert User Assignment (#171307) --- .../common/api/detection_engine/index.ts | 1 + .../right/components/assignees.tsx | 14 +- .../right/components/test_ids.ts | 1 + .../__snapshots__/index.test.tsx.snap | 2 + .../default_license/alerts/alert_assignees.ts | 137 +++++ .../utils/alerts/alert_assignees.ts | 25 + .../detections_response/utils/alerts/index.ts | 1 + .../detection_alerts/alert_assignees.cy.ts | 483 ++++++++++++++++-- .../cypress/screens/alerts.ts | 33 +- .../alert_details_right_panel.ts | 3 + .../cypress/tasks/alert_assignees.ts | 223 ++++++-- .../cypress/tasks/alerts.ts | 30 -- 12 files changed, 840 insertions(+), 113 deletions(-) create mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/alert_assignees.ts create mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/alerts/alert_assignees.ts 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/public/flyout/document_details/right/components/assignees.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees.tsx index 38fc1067000fc..e1e6d209ccfdb 100644 --- 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 @@ -20,7 +20,11 @@ import type { AssigneesIdsSelection } from '../../../../common/components/assign 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_TITLE_TEST_ID } from './test_ids'; +import { + ASSIGNEES_ADD_BUTTON_TEST_ID, + ASSIGNEES_HEADER_TEST_ID, + ASSIGNEES_TITLE_TEST_ID, +} from './test_ids'; const UpdateAssigneesButton: FC<{ togglePopover: () => void; isDisabled: boolean }> = memo( ({ togglePopover, isDisabled }) => ( @@ -103,7 +107,13 @@ export const Assignees: FC = memo( ); return ( - +

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 7b3800454e5fe..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,7 @@ 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; 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 1a99f549759a6..a78ee98b8e61e 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 @@ -80,6 +80,7 @@ Array [ >
{ + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const log = getService('log'); + const es = getService('es'); + // TODO: add a new service + 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', () => { + 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]: Invalid value "[]" supplied to "ids"', + 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]: Invalid value "" supplied to "ids"', + 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 () => { + // TODO: ... + }); + + it('should add new assignees to multiple alerts', async () => { + // TODO: ... + }); + + it('should update assignees for single alert', async () => { + // TODO: ... + }); + + it('should update assignees for multiple alerts', async () => { + // TODO: ... + }); + + it('should remove assignees from single alert', async () => { + // TODO: ... + }); + + it('should remove assignees from multiple alerts', async () => { + // TODO: ... + }); + }); + + describe('authorization / RBAC', () => { + it('should not allow viewer user to assign alerts', async () => { + // TODO: ... + }); + + it('SERVERLESS ONLY!!! serverless roles', async () => { + // TODO: ... + // 't1_analyst', 't2_analyst', 't3_analyst', 'rule_author', 'soc_manager', 'detections_admin', 'platform_engineer' + }); + + it('should not expose assignment functionality in Basic license', async () => { + // TODO: ... + }); + }); + }); + }); +}; 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 975c6ffa509cc..19115e20d3056 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 @@ -20,4 +20,5 @@ export * from './get_alert_status_empty_response'; export * from './get_query_alert_ids'; export * from './set_alert_tags'; export * from './get_preview_alerts'; +export * from './alert_assignees'; export * from './migrations'; diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/alert_assignees.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/alert_assignees.cy.ts index 0073b4a5fabcc..5128adc71af83 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/alert_assignees.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/alert_assignees.cy.ts @@ -5,14 +5,16 @@ * 2.0. */ +import { ROLES, SecurityRoleName } from '@kbn/security-solution-plugin/common/test'; import { getNewRule } from '../../../objects/rule'; import { - clickAlertAssignee, - findSelectedAlertAssignee, - findUnselectedAlertAssignee, - openAlertAssigningBulkActionMenu, + closeAlertFlyout, + closeAlerts, + expandFirstAlert, + refreshAlertPageFilter, + selectFirstPageAlerts, selectNumberOfAlerts, - updateAlertAssignees, + selectPageFilterValue, } from '../../../tasks/alerts'; import { createRule } from '../../../tasks/api_calls/rules'; import { deleteAlertsAndRules } from '../../../tasks/common'; @@ -20,51 +22,444 @@ import { login } from '../../../tasks/login'; import { visitWithTimeRange } from '../../../tasks/navigation'; import { ALERTS_URL } from '../../../urls/navigation'; import { waitForAlertsToPopulate } from '../../../tasks/create_new_rule'; -import { ALERTS_TABLE_ROW_LOADER } from '../../../screens/alerts'; import { - waitForAssigneesToPopulatePopover, - waitForAssigneeToAppearInTable, - waitForAssigneeToDisappearInTable, + alertDetailsFlyoutShowsAssignees, + alertDetailsFlyoutShowsAssigneesBadge, + alertsTableShowsAssigneesBadgeForAlert, + alertsTableShowsAssigneesForAlert, + updateAssigneesForAlert, + checkEmptyAssigneesStateInAlertDetailsFlyout, + checkEmptyAssigneesStateInAlertsTable, + removeAllAssigneesForAlert, + bulkUpdateAssignees, + alertsTableShowsAssigneesForAllAlerts, + bulkRemoveAllAssignees, + filterByAssignees, + NO_ASSIGNEES, + clearAssigneesFilter, + asigneesMenuItemsAreNotAvailable, + cannotAddAssigneesViaDetailsFlyout, + updateAssigneesViaAddButtonInFlyout, + updateAssigneesViaTakeActionButtonInFlyout, + removeAllAssigneesViaTakeActionButtonInFlyout, } from '../../../tasks/alert_assignees'; +import { PAGE_TITLE } from '../../../screens/common/page'; +import { ALERTS_COUNT } from '../../../screens/alerts'; -describe('Alert assigning', { tags: ['@ess', '@serverless'] }, () => { - beforeEach(() => { - login(); - deleteAlertsAndRules(); - cy.task('esArchiverLoad', { archiveName: 'endpoint' }); - createRule(getNewRule({ rule_id: 'new custom rule' })); - visitWithTimeRange(ALERTS_URL); - waitForAlertsToPopulate(); +const waitForPageTitleToBeShown = () => { + cy.get(PAGE_TITLE).should('be.visible'); +}; + +const loadPageAs = (url: string, role?: SecurityRoleName) => { + login(role); + visitWithTimeRange(url, { role }); + waitForPageTitleToBeShown(); +}; + +describe('Alert user assignment', { 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'); + }); + + context('Basic rendering', { tags: ['@ess', '@serverless'] }, () => { + beforeEach(() => { + loadPageAs(ALERTS_URL); + deleteAlertsAndRules(); + createRule(getNewRule({ rule_id: 'new custom rule' })); + waitForAlertsToPopulate(); + }); + + 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)', { tags: ['@ess', '@serverless'] }, () => { + beforeEach(() => { + loadPageAs(ALERTS_URL); + deleteAlertsAndRules(); + createRule(getNewRule({ rule_id: 'new custom rule' })); + waitForAlertsToPopulate(); + }); + + 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(); + }); }); - afterEach(() => { - cy.task('esArchiverUnload', 'endpoint'); + context('Updating assignees (bulk actions)', { tags: ['@ess', '@serverless'] }, () => { + beforeEach(() => { + loadPageAs(ALERTS_URL); + deleteAlertsAndRules(); + createRule(getNewRule({ rule_id: 'new custom rule' })); + waitForAlertsToPopulate(); + }); + + 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', { tags: ['@ess', '@serverless'] }, () => { + beforeEach(() => { + loadPageAs(ALERTS_URL); + deleteAlertsAndRules(); + createRule(getNewRule({ rule_id: 'new custom rule' })); + waitForAlertsToPopulate(); + }); + + 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); + }); }); - it('Add and remove an assignee using the alert bulk action menu', () => { - const userName = Cypress.env('ELASTICSEARCH_USERNAME'); - - // Add an assignee to one alert - selectNumberOfAlerts(1); - openAlertAssigningBulkActionMenu(); - waitForAssigneesToPopulatePopover(); - clickAlertAssignee(userName); - updateAlertAssignees(); - cy.get(ALERTS_TABLE_ROW_LOADER).should('not.exist'); - waitForAssigneeToAppearInTable(userName); - selectNumberOfAlerts(1); - openAlertAssigningBulkActionMenu(); - waitForAssigneesToPopulatePopover(); - findSelectedAlertAssignee(userName); - - // Remove assignee from that alert - clickAlertAssignee(userName); - updateAlertAssignees(); - cy.get(ALERTS_TABLE_ROW_LOADER).should('not.exist'); - waitForAssigneeToDisappearInTable(userName); - selectNumberOfAlerts(1); - openAlertAssigningBulkActionMenu(); - waitForAssigneesToPopulatePopover(); - findUnselectedAlertAssignee(userName); + context('Authorization / RBAC', () => { + beforeEach(() => { + loadPageAs(ALERTS_URL); + deleteAlertsAndRules(); + createRule(getNewRule({ rule_id: 'new custom rule' })); + waitForAlertsToPopulate(); + }); + + it( + 'viewer/reader should not be able to update assignees', + { tags: ['@ess', '@brokenInServerless'] }, + () => { + loadPageAs(ALERTS_URL, ROLES.reader); + waitForAlertsToPopulate(); + + asigneesMenuItemsAreNotAvailable(); + + expandFirstAlert(); + cannotAddAssigneesViaDetailsFlyout(); + } + ); + + it( + 'users with editing privileges should be able to update assignees', + { tags: ['@serverless'] }, + () => { + const editors = [ + ROLES.t1_analyst, + ROLES.t2_analyst, + // TODO: uncomment when https://github.com/elastic/kibana/pull/170778 has been merged + // 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]); + }); + } + ); + + it( + 'user with Basic license should not be able to update assignees', + { tags: ['@ess', '@brokenInServerless'] }, + () => { + // TODO: write this test!! + } + ); }); }); 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 7ca67a67629cd..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"]'; @@ -181,14 +183,35 @@ export const ALERT_RENDERER_HOST_NAME = export const HOVER_ACTIONS_CONTAINER = getDataTestSubjectSelector('hover-actions-container'); -export const ALERT_ASSIGNING_CONTEXT_MENU_ITEM = +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_ASSIGNING_SELECTABLE_MENU_ITEM = - '[data-test-subj="alert-assignees-selectable-menu"]'; +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_ASSIGNING_UPDATE_BUTTON = +export const ALERT_ASSIGNEES_UPDATE_BUTTON = '[data-test-subj="securitySolutionAssigneesApplyButton"]'; -export const ALERT_ASSIGNING_USER_AVATAR = (assignee: string) => +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_assignees.ts b/x-pack/test/security_solution_cypress/cypress/tasks/alert_assignees.ts index 8e89bc3e2d52c..f6de316074ee8 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/alert_assignees.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/alert_assignees.ts @@ -5,14 +5,37 @@ * 2.0. */ -import { ALERT_ASSIGNING_UPDATE_BUTTON, ALERT_ASSIGNING_USER_AVATAR } from '../screens/alerts'; +import { 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 { DOCUMENT_DETAILS_FLYOUT_HEADER_ASSIGNEES } from '../screens/expandable_flyout/alert_details_right_panel'; +import { selectFirstPageAlerts } from './alerts'; + +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_ASSIGNING_UPDATE_BUTTON); + const $updateButton = $el.find(ALERT_ASSIGNEES_UPDATE_BUTTON); return !$updateButton.prop('disabled'); }); }, @@ -20,36 +43,172 @@ export const waitForAssigneesToPopulatePopover = () => { ); }; -export const waitForAssigneeToAppearInTable = (userName: string) => { - cy.reload(); - cy.waitUntil( - () => { - cy.log('Waiting for assignees to appear in the "Assignees" column'); - return cy.root().then(($el) => { - const assigneesState = $el.find(`.euiAvatar${ALERT_ASSIGNING_USER_AVATAR(userName)}`); - if (assigneesState.length > 0) { - return true; - } - return false; - }); - }, - { interval: 500, timeout: 12000 } - ); +export const openAlertAssigningActionMenu = (alertIndex = 0) => { + cy.get(TIMELINE_CONTEXT_MENU_BTN).eq(alertIndex).click(); + cy.get(ALERT_ASSIGN_CONTEXT_MENU_ITEM).click(); }; -export const waitForAssigneeToDisappearInTable = (userName: string) => { - cy.reload(); - cy.waitUntil( - () => { - cy.log('Waiting for assignees to disappear in the "Assignees" column'); - return cy.root().then(($el) => { - const assigneesState = $el.find(`.euiAvatar${ALERT_ASSIGNING_USER_AVATAR(userName)}`); - if (assigneesState.length > 0) { - return false; - } - return true; - }); - }, - { interval: 500, timeout: 12000 } - ); +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 asigneesMenuItemsAreNotAvailable = () => { + cy.get(TIMELINE_CONTEXT_MENU_BTN).should('not.exist'); + + selectFirstPageAlerts(); + cy.get(TAKE_ACTION_POPOVER_BTN).click(); + cy.get(ALERT_ASSIGN_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(); }; diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/alerts.ts b/x-pack/test/security_solution_cypress/cypress/tasks/alerts.ts index 4df05787756e1..cc1a06d3545de 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/alerts.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/alerts.ts @@ -43,9 +43,6 @@ import { ALERTS_HISTOGRAM_LEGEND, LEGEND_ACTIONS, SESSION_VIEWER_BUTTON, - ALERT_ASSIGNING_CONTEXT_MENU_ITEM, - ALERT_ASSIGNING_SELECTABLE_MENU_ITEM, - ALERT_ASSIGNING_UPDATE_BUTTON, ALERT_TAGGING_CONTEXT_MENU_ITEM, ALERT_TAGGING_CONTEXT_MENU, ALERT_TAGGING_UPDATE_BUTTON, @@ -495,30 +492,3 @@ export const switchAlertTableToGridView = () => { cy.get(ALERT_TABLE_SUMMARY_VIEW_SELECTABLE).should('be.visible').trigger('click'); cy.get(ALERT_TABLE_GRID_VIEW_OPTION).should('be.visible').trigger('click'); }; - -export const openAlertAssigningBulkActionMenu = () => { - cy.get(TAKE_ACTION_POPOVER_BTN).click(); - cy.get(ALERT_ASSIGNING_CONTEXT_MENU_ITEM).click(); -}; - -export const clickAlertAssignee = (assignee: string) => { - cy.get(ALERT_ASSIGNING_SELECTABLE_MENU_ITEM).contains(assignee).click(); -}; - -export const updateAlertAssignees = () => { - cy.get(ALERT_ASSIGNING_UPDATE_BUTTON).click(); -}; - -export const findSelectedAlertAssignee = (assignee: string) => { - cy.get(ALERT_ASSIGNING_SELECTABLE_MENU_ITEM) - .find('[aria-checked="true"]') - .first() - .contains(assignee); -}; - -export const findUnselectedAlertAssignee = (assignee: string) => { - cy.get(ALERT_ASSIGNING_SELECTABLE_MENU_ITEM) - .find('[aria-checked="false"]') - .first() - .contains(assignee); -}; From 8a1c67a4e66bfdc7b696dde442dc2447c6a183aa Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Wed, 22 Nov 2023 18:43:02 +0100 Subject: [PATCH 37/53] Revert "[Security Solution][Detections] Add test coverage for Alert User Assignment (#171307)" This reverts commit 695744bc931b833461b1026769a4d167e622f79a. --- .../common/api/detection_engine/index.ts | 1 - .../right/components/assignees.tsx | 14 +- .../right/components/test_ids.ts | 1 - .../__snapshots__/index.test.tsx.snap | 2 - .../default_license/alerts/alert_assignees.ts | 137 ----- .../utils/alerts/alert_assignees.ts | 25 - .../detections_response/utils/alerts/index.ts | 1 - .../detection_alerts/alert_assignees.cy.ts | 483 ++---------------- .../cypress/screens/alerts.ts | 33 +- .../alert_details_right_panel.ts | 3 - .../cypress/tasks/alert_assignees.ts | 223 ++------ .../cypress/tasks/alerts.ts | 30 ++ 12 files changed, 113 insertions(+), 840 deletions(-) delete mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/alert_assignees.ts delete mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/alerts/alert_assignees.ts 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 56c6d4225f745..eadf1e48e9e31 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,7 +5,6 @@ * 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/public/flyout/document_details/right/components/assignees.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees.tsx index e1e6d209ccfdb..38fc1067000fc 100644 --- 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 @@ -20,11 +20,7 @@ import type { AssigneesIdsSelection } from '../../../../common/components/assign 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'; +import { ASSIGNEES_ADD_BUTTON_TEST_ID, ASSIGNEES_TITLE_TEST_ID } from './test_ids'; const UpdateAssigneesButton: FC<{ togglePopover: () => void; isDisabled: boolean }> = memo( ({ togglePopover, isDisabled }) => ( @@ -107,13 +103,7 @@ export const Assignees: FC = memo( ); return ( - +

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 5b176a34014ab..7b3800454e5fe 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,7 +19,6 @@ 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; 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 a78ee98b8e61e..1a99f549759a6 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 @@ -80,7 +80,6 @@ Array [ >
{ - const supertest = getService('supertest'); - const esArchiver = getService('esArchiver'); - const log = getService('log'); - const es = getService('es'); - // TODO: add a new service - 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', () => { - 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]: Invalid value "[]" supplied to "ids"', - 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]: Invalid value "" supplied to "ids"', - 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 () => { - // TODO: ... - }); - - it('should add new assignees to multiple alerts', async () => { - // TODO: ... - }); - - it('should update assignees for single alert', async () => { - // TODO: ... - }); - - it('should update assignees for multiple alerts', async () => { - // TODO: ... - }); - - it('should remove assignees from single alert', async () => { - // TODO: ... - }); - - it('should remove assignees from multiple alerts', async () => { - // TODO: ... - }); - }); - - describe('authorization / RBAC', () => { - it('should not allow viewer user to assign alerts', async () => { - // TODO: ... - }); - - it('SERVERLESS ONLY!!! serverless roles', async () => { - // TODO: ... - // 't1_analyst', 't2_analyst', 't3_analyst', 'rule_author', 'soc_manager', 'detections_admin', 'platform_engineer' - }); - - it('should not expose assignment functionality in Basic license', async () => { - // TODO: ... - }); - }); - }); - }); -}; 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 deleted file mode 100644 index 59c70d5d6bd9e..0000000000000 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/alerts/alert_assignees.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { 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 19115e20d3056..975c6ffa509cc 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 @@ -20,5 +20,4 @@ export * from './get_alert_status_empty_response'; export * from './get_query_alert_ids'; export * from './set_alert_tags'; export * from './get_preview_alerts'; -export * from './alert_assignees'; export * from './migrations'; diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/alert_assignees.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/alert_assignees.cy.ts index 5128adc71af83..0073b4a5fabcc 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/alert_assignees.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/alert_assignees.cy.ts @@ -5,16 +5,14 @@ * 2.0. */ -import { ROLES, SecurityRoleName } from '@kbn/security-solution-plugin/common/test'; import { getNewRule } from '../../../objects/rule'; import { - closeAlertFlyout, - closeAlerts, - expandFirstAlert, - refreshAlertPageFilter, - selectFirstPageAlerts, + clickAlertAssignee, + findSelectedAlertAssignee, + findUnselectedAlertAssignee, + openAlertAssigningBulkActionMenu, selectNumberOfAlerts, - selectPageFilterValue, + updateAlertAssignees, } from '../../../tasks/alerts'; import { createRule } from '../../../tasks/api_calls/rules'; import { deleteAlertsAndRules } from '../../../tasks/common'; @@ -22,444 +20,51 @@ import { login } from '../../../tasks/login'; import { visitWithTimeRange } from '../../../tasks/navigation'; import { ALERTS_URL } from '../../../urls/navigation'; import { waitForAlertsToPopulate } from '../../../tasks/create_new_rule'; +import { ALERTS_TABLE_ROW_LOADER } from '../../../screens/alerts'; import { - alertDetailsFlyoutShowsAssignees, - alertDetailsFlyoutShowsAssigneesBadge, - alertsTableShowsAssigneesBadgeForAlert, - alertsTableShowsAssigneesForAlert, - updateAssigneesForAlert, - checkEmptyAssigneesStateInAlertDetailsFlyout, - checkEmptyAssigneesStateInAlertsTable, - removeAllAssigneesForAlert, - bulkUpdateAssignees, - alertsTableShowsAssigneesForAllAlerts, - bulkRemoveAllAssignees, - filterByAssignees, - NO_ASSIGNEES, - clearAssigneesFilter, - asigneesMenuItemsAreNotAvailable, - cannotAddAssigneesViaDetailsFlyout, - updateAssigneesViaAddButtonInFlyout, - updateAssigneesViaTakeActionButtonInFlyout, - removeAllAssigneesViaTakeActionButtonInFlyout, + waitForAssigneesToPopulatePopover, + waitForAssigneeToAppearInTable, + waitForAssigneeToDisappearInTable, } from '../../../tasks/alert_assignees'; -import { PAGE_TITLE } from '../../../screens/common/page'; -import { ALERTS_COUNT } from '../../../screens/alerts'; -const waitForPageTitleToBeShown = () => { - cy.get(PAGE_TITLE).should('be.visible'); -}; - -const loadPageAs = (url: string, role?: SecurityRoleName) => { - login(role); - visitWithTimeRange(url, { role }); - waitForPageTitleToBeShown(); -}; - -describe('Alert user assignment', { 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'); - }); - - context('Basic rendering', { tags: ['@ess', '@serverless'] }, () => { - beforeEach(() => { - loadPageAs(ALERTS_URL); - deleteAlertsAndRules(); - createRule(getNewRule({ rule_id: 'new custom rule' })); - waitForAlertsToPopulate(); - }); - - 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)', { tags: ['@ess', '@serverless'] }, () => { - beforeEach(() => { - loadPageAs(ALERTS_URL); - deleteAlertsAndRules(); - createRule(getNewRule({ rule_id: 'new custom rule' })); - waitForAlertsToPopulate(); - }); - - 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(); - }); +describe('Alert assigning', { tags: ['@ess', '@serverless'] }, () => { + beforeEach(() => { + login(); + deleteAlertsAndRules(); + cy.task('esArchiverLoad', { archiveName: 'endpoint' }); + createRule(getNewRule({ rule_id: 'new custom rule' })); + visitWithTimeRange(ALERTS_URL); + waitForAlertsToPopulate(); }); - context('Updating assignees (bulk actions)', { tags: ['@ess', '@serverless'] }, () => { - beforeEach(() => { - loadPageAs(ALERTS_URL); - deleteAlertsAndRules(); - createRule(getNewRule({ rule_id: 'new custom rule' })); - waitForAlertsToPopulate(); - }); - - 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', { tags: ['@ess', '@serverless'] }, () => { - beforeEach(() => { - loadPageAs(ALERTS_URL); - deleteAlertsAndRules(); - createRule(getNewRule({ rule_id: 'new custom rule' })); - waitForAlertsToPopulate(); - }); - - 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); - }); + afterEach(() => { + cy.task('esArchiverUnload', 'endpoint'); }); - context('Authorization / RBAC', () => { - beforeEach(() => { - loadPageAs(ALERTS_URL); - deleteAlertsAndRules(); - createRule(getNewRule({ rule_id: 'new custom rule' })); - waitForAlertsToPopulate(); - }); - - it( - 'viewer/reader should not be able to update assignees', - { tags: ['@ess', '@brokenInServerless'] }, - () => { - loadPageAs(ALERTS_URL, ROLES.reader); - waitForAlertsToPopulate(); - - asigneesMenuItemsAreNotAvailable(); - - expandFirstAlert(); - cannotAddAssigneesViaDetailsFlyout(); - } - ); - - it( - 'users with editing privileges should be able to update assignees', - { tags: ['@serverless'] }, - () => { - const editors = [ - ROLES.t1_analyst, - ROLES.t2_analyst, - // TODO: uncomment when https://github.com/elastic/kibana/pull/170778 has been merged - // 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]); - }); - } - ); - - it( - 'user with Basic license should not be able to update assignees', - { tags: ['@ess', '@brokenInServerless'] }, - () => { - // TODO: write this test!! - } - ); + it('Add and remove an assignee using the alert bulk action menu', () => { + const userName = Cypress.env('ELASTICSEARCH_USERNAME'); + + // Add an assignee to one alert + selectNumberOfAlerts(1); + openAlertAssigningBulkActionMenu(); + waitForAssigneesToPopulatePopover(); + clickAlertAssignee(userName); + updateAlertAssignees(); + cy.get(ALERTS_TABLE_ROW_LOADER).should('not.exist'); + waitForAssigneeToAppearInTable(userName); + selectNumberOfAlerts(1); + openAlertAssigningBulkActionMenu(); + waitForAssigneesToPopulatePopover(); + findSelectedAlertAssignee(userName); + + // Remove assignee from that alert + clickAlertAssignee(userName); + updateAlertAssignees(); + cy.get(ALERTS_TABLE_ROW_LOADER).should('not.exist'); + waitForAssigneeToDisappearInTable(userName); + selectNumberOfAlerts(1); + openAlertAssigningBulkActionMenu(); + waitForAssigneesToPopulatePopover(); + findUnselectedAlertAssignee(userName); }); }); 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 cfcd52372daeb..7ca67a67629cd 100644 --- a/x-pack/test/security_solution_cypress/cypress/screens/alerts.ts +++ b/x-pack/test/security_solution_cypress/cypress/screens/alerts.ts @@ -33,8 +33,6 @@ 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"]'; @@ -183,35 +181,14 @@ export const ALERT_RENDERER_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 = +export const ALERT_ASSIGNING_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_ASSIGNING_SELECTABLE_MENU_ITEM = + '[data-test-subj="alert-assignees-selectable-menu"]'; -export const ALERT_ASSIGNEES_UPDATE_BUTTON = +export const ALERT_ASSIGNING_UPDATE_BUTTON = '[data-test-subj="securitySolutionAssigneesApplyButton"]'; -export const ALERT_USER_AVATAR = (assignee: string) => +export const ALERT_ASSIGNING_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 afe87189acd37..abf9585e368ec 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,7 +17,6 @@ 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, @@ -60,8 +59,6 @@ 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_assignees.ts b/x-pack/test/security_solution_cypress/cypress/tasks/alert_assignees.ts index f6de316074ee8..8e89bc3e2d52c 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/alert_assignees.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/alert_assignees.ts @@ -5,37 +5,14 @@ * 2.0. */ -import { 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 { DOCUMENT_DETAILS_FLYOUT_HEADER_ASSIGNEES } from '../screens/expandable_flyout/alert_details_right_panel'; -import { selectFirstPageAlerts } from './alerts'; - -export const NO_ASSIGNEES = 'No assignees'; +import { ALERT_ASSIGNING_UPDATE_BUTTON, ALERT_ASSIGNING_USER_AVATAR } from '../screens/alerts'; 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); + const $updateButton = $el.find(ALERT_ASSIGNING_UPDATE_BUTTON); return !$updateButton.prop('disabled'); }); }, @@ -43,172 +20,36 @@ export const waitForAssigneesToPopulatePopover = () => { ); }; -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 asigneesMenuItemsAreNotAvailable = () => { - cy.get(TIMELINE_CONTEXT_MENU_BTN).should('not.exist'); - - selectFirstPageAlerts(); - cy.get(TAKE_ACTION_POPOVER_BTN).click(); - cy.get(ALERT_ASSIGN_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 waitForAssigneeToAppearInTable = (userName: string) => { + cy.reload(); + cy.waitUntil( + () => { + cy.log('Waiting for assignees to appear in the "Assignees" column'); + return cy.root().then(($el) => { + const assigneesState = $el.find(`.euiAvatar${ALERT_ASSIGNING_USER_AVATAR(userName)}`); + if (assigneesState.length > 0) { + return true; + } + return false; + }); + }, + { interval: 500, timeout: 12000 } + ); }; -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(); +export const waitForAssigneeToDisappearInTable = (userName: string) => { + cy.reload(); + cy.waitUntil( + () => { + cy.log('Waiting for assignees to disappear in the "Assignees" column'); + return cy.root().then(($el) => { + const assigneesState = $el.find(`.euiAvatar${ALERT_ASSIGNING_USER_AVATAR(userName)}`); + if (assigneesState.length > 0) { + return false; + } + return true; + }); + }, + { interval: 500, timeout: 12000 } + ); }; diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/alerts.ts b/x-pack/test/security_solution_cypress/cypress/tasks/alerts.ts index cc1a06d3545de..4df05787756e1 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/alerts.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/alerts.ts @@ -43,6 +43,9 @@ import { ALERTS_HISTOGRAM_LEGEND, LEGEND_ACTIONS, SESSION_VIEWER_BUTTON, + ALERT_ASSIGNING_CONTEXT_MENU_ITEM, + ALERT_ASSIGNING_SELECTABLE_MENU_ITEM, + ALERT_ASSIGNING_UPDATE_BUTTON, ALERT_TAGGING_CONTEXT_MENU_ITEM, ALERT_TAGGING_CONTEXT_MENU, ALERT_TAGGING_UPDATE_BUTTON, @@ -492,3 +495,30 @@ export const switchAlertTableToGridView = () => { cy.get(ALERT_TABLE_SUMMARY_VIEW_SELECTABLE).should('be.visible').trigger('click'); cy.get(ALERT_TABLE_GRID_VIEW_OPTION).should('be.visible').trigger('click'); }; + +export const openAlertAssigningBulkActionMenu = () => { + cy.get(TAKE_ACTION_POPOVER_BTN).click(); + cy.get(ALERT_ASSIGNING_CONTEXT_MENU_ITEM).click(); +}; + +export const clickAlertAssignee = (assignee: string) => { + cy.get(ALERT_ASSIGNING_SELECTABLE_MENU_ITEM).contains(assignee).click(); +}; + +export const updateAlertAssignees = () => { + cy.get(ALERT_ASSIGNING_UPDATE_BUTTON).click(); +}; + +export const findSelectedAlertAssignee = (assignee: string) => { + cy.get(ALERT_ASSIGNING_SELECTABLE_MENU_ITEM) + .find('[aria-checked="true"]') + .first() + .contains(assignee); +}; + +export const findUnselectedAlertAssignee = (assignee: string) => { + cy.get(ALERT_ASSIGNING_SELECTABLE_MENU_ITEM) + .find('[aria-checked="false"]') + .first() + .contains(assignee); +}; From 23cb6a993d0d087dfc6eed53165748e9518f841f Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Wed, 22 Nov 2023 13:05:43 +0100 Subject: [PATCH 38/53] Hide assigning functionality in Basic license --- .../use_bulk_alert_assignees_items.tsx | 11 +++++++---- .../pages/detection_engine/detection_engine.tsx | 17 +++++++++++------ .../right/components/assignees.tsx | 8 +++++++- 3 files changed, 25 insertions(+), 11 deletions(-) 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 index 1f5d7b0cab233..46fed23c1214b 100644 --- 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 @@ -15,6 +15,7 @@ import type { 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'; @@ -36,6 +37,8 @@ export interface UseBulkAlertAssigneesPanel { export const useBulkAlertAssigneesItems = ({ onAssigneesUpdate, }: UseBulkAlertAssigneesItemsProps) => { + const isPlatinumPlus = useLicense().isPlatinumPlus(); + const { hasIndexWrite } = useAlertsPrivileges(); const setAlertAssignees = useSetAlertAssignees(); @@ -77,7 +80,7 @@ export const useBulkAlertAssigneesItems = ({ const alertAssigneesItems = useMemo( () => - hasIndexWrite + hasIndexWrite && isPlatinumPlus ? [ { key: 'manage-alert-assignees', @@ -97,7 +100,7 @@ export const useBulkAlertAssigneesItems = ({ }, ] : [], - [hasIndexWrite, onRemoveAllAssignees] + [hasIndexWrite, isPlatinumPlus, onRemoveAllAssignees] ); const TitleContent = useMemo( @@ -134,7 +137,7 @@ export const useBulkAlertAssigneesItems = ({ const alertAssigneesPanels: UseBulkAlertAssigneesPanel[] = useMemo( () => - hasIndexWrite + hasIndexWrite && isPlatinumPlus ? [ { id: 2, @@ -145,7 +148,7 @@ export const useBulkAlertAssigneesItems = ({ }, ] : [], - [TitleContent, hasIndexWrite, renderContent] + [TitleContent, hasIndexWrite, isPlatinumPlus, renderContent] ); return { 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 2127e0c4f26a7..ad754f3fe57c9 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 @@ -32,6 +32,7 @@ import { TableId, } from '@kbn/securitysolution-data-table'; import { isEqual } from 'lodash'; +import { useLicense } from '../../../common/hooks/use_license'; 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'; @@ -139,6 +140,8 @@ const DetectionEnginePageComponent: React.FC = ({ const { loading: listsConfigLoading, needsConfiguration: needsListsConfiguration } = useListsConfig(); + const isPlatinumPlus = useLicense().isPlatinumPlus(); + const [assignees, setAssignees] = useState([]); const handleSelectedAssignees = useCallback( (newAssignees: AssigneesIdsSelection[]) => { @@ -465,12 +468,14 @@ const DetectionEnginePageComponent: React.FC = ({ - - - + {isPlatinumPlus && ( + + + + )} = memo( ({ eventId, assignedUserIds, onAssigneesUpdated }) => { + const isPlatinumPlus = useLicense().isPlatinumPlus(); + const { hasIndexWrite } = useAlertsPrivileges(); const setAlertAssignees = useSetAlertAssignees(); @@ -123,7 +126,10 @@ export const Assignees: FC = memo( + } isPopoverOpen={isPopoverOpen} closePopover={togglePopover} From 1735f5ae43574ac10194e436deb883f2df193f8a Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Thu, 23 Nov 2023 18:44:51 +0100 Subject: [PATCH 39/53] Update x-pack/plugins/triggers_actions_ui/public/application/alert_table_config_registry.ts Co-authored-by: Xavier Mouligneau --- .../public/application/alert_table_config_registry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/alert_table_config_registry.ts b/x-pack/plugins/triggers_actions_ui/public/application/alert_table_config_registry.ts index 62dc289730a0c..42b10da236e16 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/alert_table_config_registry.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/alert_table_config_registry.ts @@ -28,7 +28,7 @@ export class AlertTableConfigRegistry { /** * Registers an object type to the type registry */ - public register(objectType: AlertsTableConfigurationRegistry) { + public register(objectType: AlertsTableConfigurationRegistry) { if (this.has(objectType.id)) { throw new Error( i18n.translate( From 995ae134bcad402b426137b63698b643351b44bb Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Fri, 24 Nov 2023 00:48:32 +0100 Subject: [PATCH 40/53] Fix broken tests --- .../use_bulk_alert_assignees_items.test.tsx | 13 +++++++++++++ .../timeline_actions/alert_context_menu.test.tsx | 4 ++++ .../use_alert_assignees_actions.test.tsx | 11 +++++++++++ .../components/take_action_dropdown/index.test.tsx | 4 ++++ .../right/components/assignees.test.tsx | 13 +++++++++++++ 5 files changed, 45 insertions(+) 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 index cdba448f05b76..7a6b9c87fa27e 100644 --- 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 @@ -23,12 +23,14 @@ import { useBulkGetUserProfiles } from '../../user_profiles/use_bulk_get_user_pr 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: {} }, @@ -72,6 +74,7 @@ describe('useBulkAlertAssigneesItems', () => { data: mockUserProfiles, }); (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasIndexWrite: true }); + (useLicense as jest.Mock).mockReturnValue({ isPlatinumPlus: () => true }); }); afterEach(() => { @@ -176,4 +179,14 @@ describe('useBulkAlertAssigneesItems', () => { 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/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 80cf5c88dd879..dfc16f55461e9 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'] }, 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 index a011375b78b1d..52c196fa729fc 100644 --- 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 @@ -19,12 +19,14 @@ import { useSetAlertAssignees } from '../../../../common/components/toolbar/bulk 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: {} }, @@ -67,6 +69,7 @@ describe('useAlertAssigneesActions', () => { (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasIndexWrite: true, }); + (useLicense as jest.Mock).mockReturnValue({ isPlatinumPlus: () => true }); }); afterEach(() => { @@ -125,6 +128,14 @@ describe('useAlertAssigneesActions', () => { 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, 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 f63079922d583..78b2ba12f8519 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 @@ -62,6 +62,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), })); 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 index 693ee728a8188..50da260949ec9 100644 --- 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 @@ -18,6 +18,7 @@ import type { SetAlertAssigneesFunc } from '../../../../common/components/toolba 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 { USERS_AVATARS_COUNT_BADGE_TEST_ID, USERS_AVATARS_PANEL_TEST_ID, @@ -29,6 +30,7 @@ jest.mock('../../../../common/components/user_profiles/use_get_current_user_prof 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('../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'); const mockUserProfiles = [ @@ -72,6 +74,7 @@ describe('', () => { data: mockUserProfiles, }); (useAlertsPrivileges as jest.Mock).mockReturnValue({ hasIndexWrite: true }); + (useLicense as jest.Mock).mockReturnValue({ isPlatinumPlus: () => true }); setAlertAssigneesMock = jest.fn().mockReturnValue(Promise.resolve()); (useSetAlertAssignees as jest.Mock).mockReturnValue(setAlertAssigneesMock); @@ -147,4 +150,14 @@ describe('', () => { 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(); + }); }); From 9943139fe9ea2100b65d976b83595dbc90d2b2ff Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Fri, 24 Nov 2023 00:53:21 +0100 Subject: [PATCH 41/53] Fix typing errors --- .../register_alerts_table_configuration.tsx | 6 +++--- .../security_solution_detections/render_cell_value.tsx | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) 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 316f1a441721e..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 @@ -112,10 +112,10 @@ const registerAlertsTableConfiguration = ( }); }; -const registerIfNotAlready: ( +const registerIfNotAlready = ( registry: AlertsTableConfigurationRegistryContract, - registryArgs: AlertsTableConfigurationRegistry -) => void = (registry, registryArgs) => { + registryArgs: AlertsTableConfigurationRegistry +) => { if (!registry.has(registryArgs.id)) { registry.register(registryArgs); } 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 5b4f33f9fa0b4..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 @@ -96,7 +96,7 @@ export const getRenderCellValueHook = ({ scopeId: SourcererScopeName; tableId: TableId; }) => { - const useRenderCellValue: GetRenderCellValue = ({ context }) => { + const useRenderCellValue: GetRenderCellValue = ({ context }) => { const { browserFields } = useSourcererDataView(scopeId); const browserFieldsByName = useMemo(() => getAllFieldsByName(browserFields), [browserFields]); const getTable = useMemo(() => dataTableSelectors.getTableByIdSelector(), []); @@ -174,7 +174,7 @@ export const getRenderCellValueHook = ({ scopeId={tableId} truncate={truncate} asPlainText={false} - context={context} + context={context as RenderCellValueContext} /> ); }, From 73aeaa03eec33073619f17a8b9002df8a995b8e4 Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Fri, 24 Nov 2023 01:10:02 +0100 Subject: [PATCH 42/53] Revert changes to Cases plugin Updated by mistake here https://github.com/elastic/kibana/pull/170579/commits/38dbb5a7e35018fbb8e66ddf2dbad1a3febcc512 --- .../actions/assignees/edit_assignees_selectable.test.tsx | 6 +++--- .../cases/public/components/user_profiles/translations.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/cases/public/components/actions/assignees/edit_assignees_selectable.test.tsx b/x-pack/plugins/cases/public/components/actions/assignees/edit_assignees_selectable.test.tsx index fd64b4ceb42be..60a19df11b04a 100644 --- a/x-pack/plugins/cases/public/components/actions/assignees/edit_assignees_selectable.test.tsx +++ b/x-pack/plugins/cases/public/components/actions/assignees/edit_assignees_selectable.test.tsx @@ -441,15 +441,15 @@ describe('EditAssigneesSelectable', () => { }); }); - it('unassign alert', async () => { + it('remove all assignees', async () => { const result = appMock.render(); await waitFor(() => { expect(result.getByTestId('cases-actions-assignees-edit-selectable')).toBeInTheDocument(); }); - expect(result.getByRole('button', { name: 'Unassign alert' })).toBeInTheDocument(); - userEvent.click(result.getByRole('button', { name: 'Unassign alert' })); + expect(result.getByRole('button', { name: 'Remove all assignees' })).toBeInTheDocument(); + userEvent.click(result.getByRole('button', { name: 'Remove all assignees' })); expect(propsMultipleCases.onChangeAssignees).toBeCalledTimes(1); expect(propsMultipleCases.onChangeAssignees).toBeCalledWith({ diff --git a/x-pack/plugins/cases/public/components/user_profiles/translations.ts b/x-pack/plugins/cases/public/components/user_profiles/translations.ts index 92fae7acc2747..f3c338c4d7b4e 100644 --- a/x-pack/plugins/cases/public/components/user_profiles/translations.ts +++ b/x-pack/plugins/cases/public/components/user_profiles/translations.ts @@ -36,7 +36,7 @@ export const EDIT_ASSIGNEES = i18n.translate('xpack.cases.userProfile.editAssign export const REMOVE_ASSIGNEES = i18n.translate( 'xpack.cases.userProfile.suggestUsers.removeAssignees', { - defaultMessage: 'Unassign alert', + defaultMessage: 'Remove all assignees', } ); From a634bba9758f59c32bda4450d7bf7d74cd1c55a6 Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Fri, 24 Nov 2023 01:19:20 +0100 Subject: [PATCH 43/53] Revert changes to Cases plugin Updated by mistake here https://github.com/elastic/kibana/pull/170579/commits/38dbb5a7e35018fbb8e66ddf2dbad1a3febcc512 --- .../test/functional_with_es_ssl/apps/cases/group1/view_case.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts b/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts index 171917782e369..9c33f6720f8b5 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts @@ -921,7 +921,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await cases.common.setSearchTextInAssigneesPopover('case'); await cases.common.selectFirstRowInAssigneesPopover(); - await (await find.byButtonText('Unassign alert')).click(); + await (await find.byButtonText('Remove all assignees')).click(); await cases.singleCase.closeAssigneesPopover(); await testSubjects.missingOrFail('user-profile-assigned-user-abc-remove-group'); }); From 60e092c442e2d961a9c12db4648600a9e1063764 Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Fri, 24 Nov 2023 16:38:50 +0100 Subject: [PATCH 44/53] Fix broken tests --- .../detection_response/detection_alerts/alert_assignees.cy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/alert_assignees.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/alert_assignees.cy.ts index 0073b4a5fabcc..3b48ae7037093 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/alert_assignees.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/alert_assignees.cy.ts @@ -15,7 +15,7 @@ import { updateAlertAssignees, } from '../../../tasks/alerts'; import { createRule } from '../../../tasks/api_calls/rules'; -import { deleteAlertsAndRules } from '../../../tasks/common'; +import { deleteAlertsAndRules } from '../../../tasks/api_calls/common'; import { login } from '../../../tasks/login'; import { visitWithTimeRange } from '../../../tasks/navigation'; import { ALERTS_URL } from '../../../urls/navigation'; From e317182cbcaa9978b180c72aac1d88cdf0dab694 Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Mon, 27 Nov 2023 12:02:22 +0100 Subject: [PATCH 45/53] [Security Solution][Detections] Add test coverage for Alert User Assignment (#171307) (#171930) ## Summary Addresses https://github.com/elastic/kibana/issues/171307 This PR adds tests overage according to this test plans https://github.com/elastic/kibana/issues/171306 --- .../common/api/detection_engine/index.ts | 1 + .../right/components/assignees.tsx | 14 +- .../right/components/test_ids.ts | 1 + .../__snapshots__/index.test.tsx.snap | 2 + .../alerts/assignments/assignments.ts | 518 ++++++++++++++++++ .../alerts/assignments/assignments_ess.ts | 92 ++++ .../assignments/assignments_serverless.ts | 111 ++++ .../alerts/assignments/index.ts | 15 + .../default_license/alerts/index.ts | 1 + .../utils/alerts/alert_assignees.ts | 25 + .../detections_response/utils/alerts/index.ts | 1 + .../detection_alerts/alert_assignees.cy.ts | 70 --- .../assignments/assignments.cy.ts | 368 +++++++++++++ .../assignments/assignments_ess.cy.ts | 49 ++ .../assignments/assignments_ess_basic.cy.ts | 53 ++ .../assignments_serverless_complete.cy.ts | 88 +++ .../assignments_serverless_essentials.cy.ts | 88 +++ .../cypress/screens/alerts.ts | 33 +- .../alert_details_right_panel.ts | 3 + .../cypress/tasks/alert_assignees.ts | 55 -- .../cypress/tasks/alert_assignments.ts | 223 ++++++++ .../cypress/tasks/alerts.ts | 30 - .../cypress/tasks/navigation.ts | 13 +- 23 files changed, 1691 insertions(+), 163 deletions(-) create mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/assignments/assignments.ts create mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/assignments/assignments_ess.ts create mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/assignments/assignments_serverless.ts create mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/assignments/index.ts create mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/alerts/alert_assignees.ts delete mode 100644 x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/alert_assignees.cy.ts create mode 100644 x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/assignments/assignments.cy.ts create mode 100644 x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/assignments/assignments_ess.cy.ts create mode 100644 x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/assignments/assignments_ess_basic.cy.ts create mode 100644 x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/assignments/assignments_serverless_complete.cy.ts create mode 100644 x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/assignments/assignments_serverless_essentials.cy.ts delete mode 100644 x-pack/test/security_solution_cypress/cypress/tasks/alert_assignees.ts create mode 100644 x-pack/test/security_solution_cypress/cypress/tasks/alert_assignments.ts 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/public/flyout/document_details/right/components/assignees.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/assignees.tsx index b7b99c07920c7..550cbb16e9d7a 100644 --- 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 @@ -21,7 +21,11 @@ import type { AssigneesIdsSelection } from '../../../../common/components/assign 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_TITLE_TEST_ID } from './test_ids'; +import { + ASSIGNEES_ADD_BUTTON_TEST_ID, + ASSIGNEES_HEADER_TEST_ID, + ASSIGNEES_TITLE_TEST_ID, +} from './test_ids'; const UpdateAssigneesButton: FC<{ togglePopover: () => void; isDisabled: boolean }> = memo( ({ togglePopover, isDisabled }) => ( @@ -106,7 +110,13 @@ export const Assignees: FC = memo( ); return ( - +

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 7b3800454e5fe..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,7 @@ 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; 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 1a99f549759a6..a78ee98b8e61e 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 @@ -80,6 +80,7 @@ Array [ >
{ + 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]: Invalid value "[]" supplied to "ids"', + 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]: Invalid value "" supplied to "ids"', + 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/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/alert_assignees.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/alert_assignees.cy.ts deleted file mode 100644 index 3b48ae7037093..0000000000000 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/alert_assignees.cy.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { getNewRule } from '../../../objects/rule'; -import { - clickAlertAssignee, - findSelectedAlertAssignee, - findUnselectedAlertAssignee, - openAlertAssigningBulkActionMenu, - selectNumberOfAlerts, - updateAlertAssignees, -} from '../../../tasks/alerts'; -import { createRule } from '../../../tasks/api_calls/rules'; -import { deleteAlertsAndRules } from '../../../tasks/api_calls/common'; -import { login } from '../../../tasks/login'; -import { visitWithTimeRange } from '../../../tasks/navigation'; -import { ALERTS_URL } from '../../../urls/navigation'; -import { waitForAlertsToPopulate } from '../../../tasks/create_new_rule'; -import { ALERTS_TABLE_ROW_LOADER } from '../../../screens/alerts'; -import { - waitForAssigneesToPopulatePopover, - waitForAssigneeToAppearInTable, - waitForAssigneeToDisappearInTable, -} from '../../../tasks/alert_assignees'; - -describe('Alert assigning', { tags: ['@ess', '@serverless'] }, () => { - beforeEach(() => { - login(); - deleteAlertsAndRules(); - cy.task('esArchiverLoad', { archiveName: 'endpoint' }); - createRule(getNewRule({ rule_id: 'new custom rule' })); - visitWithTimeRange(ALERTS_URL); - waitForAlertsToPopulate(); - }); - - afterEach(() => { - cy.task('esArchiverUnload', 'endpoint'); - }); - - it('Add and remove an assignee using the alert bulk action menu', () => { - const userName = Cypress.env('ELASTICSEARCH_USERNAME'); - - // Add an assignee to one alert - selectNumberOfAlerts(1); - openAlertAssigningBulkActionMenu(); - waitForAssigneesToPopulatePopover(); - clickAlertAssignee(userName); - updateAlertAssignees(); - cy.get(ALERTS_TABLE_ROW_LOADER).should('not.exist'); - waitForAssigneeToAppearInTable(userName); - selectNumberOfAlerts(1); - openAlertAssigningBulkActionMenu(); - waitForAssigneesToPopulatePopover(); - findSelectedAlertAssignee(userName); - - // Remove assignee from that alert - clickAlertAssignee(userName); - updateAlertAssignees(); - cy.get(ALERTS_TABLE_ROW_LOADER).should('not.exist'); - waitForAssigneeToDisappearInTable(userName); - selectNumberOfAlerts(1); - openAlertAssigningBulkActionMenu(); - waitForAssigneesToPopulatePopover(); - findUnselectedAlertAssignee(userName); - }); -}); 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..b1bfc373d1385 --- /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 { loadPageAs } from '../../../../tasks/navigation'; +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, +} 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..6fddde59a88a1 --- /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 { loadPageAs } from '../../../../tasks/navigation'; +import { ALERTS_URL } from '../../../../urls/navigation'; +import { waitForAlertsToPopulate } from '../../../../tasks/create_new_rule'; +import { + alertsTableMoreActionsAreNotAvailable, + cannotAddAssigneesViaDetailsFlyout, +} 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..12881280388a3 --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_alerts/assignments/assignments_ess_basic.cy.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getNewRule } from '../../../../objects/rule'; +import { expandFirstAlert } from '../../../../tasks/alerts'; +import { createRule } from '../../../../tasks/api_calls/rules'; +import { deleteAlertsAndRules } from '../../../../tasks/api_calls/common'; +import { loadPageAs } from '../../../../tasks/navigation'; +import { ALERTS_URL } from '../../../../urls/navigation'; +import { waitForAlertsToPopulate } from '../../../../tasks/create_new_rule'; +import { + asigneesMenuItemsAreNotAvailable, + cannotAddAssigneesViaDetailsFlyout, +} from '../../../../tasks/alert_assignments'; + +describe('Alert user assignment - Basic License', { 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(); + + cy.request({ + method: 'POST', + url: '/api/license/start_basic?acknowledge=true', + headers: { + 'kbn-xsrf': 'cypress-creds', + 'x-elastic-internal-origin': 'security-solution', + }, + }); + }); + + 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..8081d89493c00 --- /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 { loadPageAs } from '../../../../tasks/navigation'; +import { ALERTS_URL } from '../../../../urls/navigation'; +import { waitForAlertsToPopulate } from '../../../../tasks/create_new_rule'; +import { + alertsTableShowsAssigneesForAlert, + updateAssigneesForAlert, + bulkRemoveAllAssignees, +} 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..b9024681d3609 --- /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 { loadPageAs } from '../../../../tasks/navigation'; +import { ALERTS_URL } from '../../../../urls/navigation'; +import { waitForAlertsToPopulate } from '../../../../tasks/create_new_rule'; +import { + alertsTableShowsAssigneesForAlert, + updateAssigneesForAlert, + bulkRemoveAllAssignees, +} 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 7ca67a67629cd..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"]'; @@ -181,14 +183,35 @@ export const ALERT_RENDERER_HOST_NAME = export const HOVER_ACTIONS_CONTAINER = getDataTestSubjectSelector('hover-actions-container'); -export const ALERT_ASSIGNING_CONTEXT_MENU_ITEM = +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_ASSIGNING_SELECTABLE_MENU_ITEM = - '[data-test-subj="alert-assignees-selectable-menu"]'; +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_ASSIGNING_UPDATE_BUTTON = +export const ALERT_ASSIGNEES_UPDATE_BUTTON = '[data-test-subj="securitySolutionAssigneesApplyButton"]'; -export const ALERT_ASSIGNING_USER_AVATAR = (assignee: string) => +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_assignees.ts b/x-pack/test/security_solution_cypress/cypress/tasks/alert_assignees.ts deleted file mode 100644 index 8e89bc3e2d52c..0000000000000 --- a/x-pack/test/security_solution_cypress/cypress/tasks/alert_assignees.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { ALERT_ASSIGNING_UPDATE_BUTTON, ALERT_ASSIGNING_USER_AVATAR } from '../screens/alerts'; - -export const waitForAssigneesToPopulatePopover = () => { - cy.waitUntil( - () => { - cy.log('Waiting for assignees to appear in popover'); - return cy.root().then(($el) => { - const $updateButton = $el.find(ALERT_ASSIGNING_UPDATE_BUTTON); - return !$updateButton.prop('disabled'); - }); - }, - { interval: 500, timeout: 12000 } - ); -}; - -export const waitForAssigneeToAppearInTable = (userName: string) => { - cy.reload(); - cy.waitUntil( - () => { - cy.log('Waiting for assignees to appear in the "Assignees" column'); - return cy.root().then(($el) => { - const assigneesState = $el.find(`.euiAvatar${ALERT_ASSIGNING_USER_AVATAR(userName)}`); - if (assigneesState.length > 0) { - return true; - } - return false; - }); - }, - { interval: 500, timeout: 12000 } - ); -}; - -export const waitForAssigneeToDisappearInTable = (userName: string) => { - cy.reload(); - cy.waitUntil( - () => { - cy.log('Waiting for assignees to disappear in the "Assignees" column'); - return cy.root().then(($el) => { - const assigneesState = $el.find(`.euiAvatar${ALERT_ASSIGNING_USER_AVATAR(userName)}`); - if (assigneesState.length > 0) { - return false; - } - return true; - }); - }, - { interval: 500, timeout: 12000 } - ); -}; 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..5d32be7827440 --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/tasks/alert_assignments.ts @@ -0,0 +1,223 @@ +/* + * 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 { 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 { DOCUMENT_DETAILS_FLYOUT_HEADER_ASSIGNEES } from '../screens/expandable_flyout/alert_details_right_panel'; +import { selectFirstPageAlerts } from './alerts'; + +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 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(); +}; diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/alerts.ts b/x-pack/test/security_solution_cypress/cypress/tasks/alerts.ts index 4df05787756e1..cc1a06d3545de 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/alerts.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/alerts.ts @@ -43,9 +43,6 @@ import { ALERTS_HISTOGRAM_LEGEND, LEGEND_ACTIONS, SESSION_VIEWER_BUTTON, - ALERT_ASSIGNING_CONTEXT_MENU_ITEM, - ALERT_ASSIGNING_SELECTABLE_MENU_ITEM, - ALERT_ASSIGNING_UPDATE_BUTTON, ALERT_TAGGING_CONTEXT_MENU_ITEM, ALERT_TAGGING_CONTEXT_MENU, ALERT_TAGGING_UPDATE_BUTTON, @@ -495,30 +492,3 @@ export const switchAlertTableToGridView = () => { cy.get(ALERT_TABLE_SUMMARY_VIEW_SELECTABLE).should('be.visible').trigger('click'); cy.get(ALERT_TABLE_GRID_VIEW_OPTION).should('be.visible').trigger('click'); }; - -export const openAlertAssigningBulkActionMenu = () => { - cy.get(TAKE_ACTION_POPOVER_BTN).click(); - cy.get(ALERT_ASSIGNING_CONTEXT_MENU_ITEM).click(); -}; - -export const clickAlertAssignee = (assignee: string) => { - cy.get(ALERT_ASSIGNING_SELECTABLE_MENU_ITEM).contains(assignee).click(); -}; - -export const updateAlertAssignees = () => { - cy.get(ALERT_ASSIGNING_UPDATE_BUTTON).click(); -}; - -export const findSelectedAlertAssignee = (assignee: string) => { - cy.get(ALERT_ASSIGNING_SELECTABLE_MENU_ITEM) - .find('[aria-checked="true"]') - .first() - .contains(assignee); -}; - -export const findUnselectedAlertAssignee = (assignee: string) => { - cy.get(ALERT_ASSIGNING_SELECTABLE_MENU_ITEM) - .find('[aria-checked="false"]') - .first() - .contains(assignee); -}; diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/navigation.ts b/x-pack/test/security_solution_cypress/cypress/tasks/navigation.ts index b7c6f55386c3f..d05b964fffe0c 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/navigation.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/navigation.ts @@ -9,8 +9,9 @@ import { encode } from '@kbn/rison'; import { NEW_FEATURES_TOUR_STORAGE_KEYS } from '@kbn/security-solution-plugin/common/constants'; import type { SecurityRoleName } from '@kbn/security-solution-plugin/common/test'; +import { PAGE_TITLE } from '../screens/common/page'; import { GET_STARTED_URL, hostDetailsUrl, userDetailsUrl } from '../urls/navigation'; -import { constructUrlWithUser, getUrlWithRoute, User } from './login'; +import { constructUrlWithUser, getUrlWithRoute, login, User } from './login'; export const visit = ( url: string, @@ -112,3 +113,13 @@ const disableNewFeaturesTours = (window: Window) => { window.localStorage.setItem(key, JSON.stringify(tourConfig)); }); }; + +export const waitForPageTitleToBeShown = () => { + cy.get(PAGE_TITLE).should('be.visible'); +}; + +export const loadPageAs = (url: string, role?: SecurityRoleName) => { + login(role); + visitWithTimeRange(url, { role }); + waitForPageTitleToBeShown(); +}; From d1adf4c17ef323380bc4b6d7a800f80c52a1b796 Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Mon, 27 Nov 2023 15:53:31 +0100 Subject: [PATCH 46/53] no-op commit From a492c784136350d2f37dbc0a01459914cb0fe1f2 Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Tue, 28 Nov 2023 12:30:05 +0100 Subject: [PATCH 47/53] Upselling tooltips for alert assignments feature within Basic license (#172014) ## Summary These changes add upselling for "Alert assignments" feature within Basic license: Screenshot 2023-11-27 at 19 51 36 **NOTE**: exact upselling message is still under design team review. --------- Co-authored-by: Sergi Massaneda --- .../upselling/messages/index.tsx | 8 ++++ .../upselling/service/types.ts | 2 +- .../filter_group/filter_by_assignees.test.tsx | 6 +++ .../filter_group/filter_by_assignees.tsx | 42 ++++++++++++----- .../detection_engine.test.tsx | 3 ++ .../detection_engine/detection_engine.tsx | 17 +++---- .../right/components/assignees.test.tsx | 3 ++ .../right/components/assignees.tsx | 47 ++++++++++--------- .../side_panel/event_details/index.test.tsx | 3 ++ .../public/upselling/register_upsellings.tsx | 10 +++- .../assignments/assignments_ess_basic.cy.ts | 25 ++++++---- 11 files changed, 110 insertions(+), 56 deletions(-) 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/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 index 35ac2096689e7..872d6f8e901a4 100644 --- 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 @@ -16,10 +16,14 @@ 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 = [ { @@ -70,6 +74,8 @@ describe('', () => { 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', () => { 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 index 03aba6e7ef004..fbef830dd1b85 100644 --- 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 @@ -9,11 +9,13 @@ import type { FC } from 'react'; import React, { memo, useCallback, useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiFilterButton, EuiFilterGroup } from '@elastic/eui'; +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 { /** @@ -32,6 +34,9 @@ export interface FilterByAssigneesPopoverProps { */ 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), []); @@ -51,19 +56,30 @@ export const FilterByAssigneesPopover: FC = memo( assignedUserIds={assignedUserIds} showUnassignedOption={true} button={ - 0} - numActiveFilters={selectedAssignees.length} + - {i18n.translate('xpack.securitySolution.filtersGroup.assignees.buttonTitle', { - defaultMessage: 'Assignees', - })} - + 0} + numActiveFilters={selectedAssignees.length} + > + {i18n.translate('xpack.securitySolution.filtersGroup.assignees.buttonTitle', { + defaultMessage: 'Assignees', + })} + + } isPopoverOpen={isPopoverOpen} closePopover={togglePopover} 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 ad754f3fe57c9..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 @@ -32,7 +32,6 @@ import { TableId, } from '@kbn/securitysolution-data-table'; import { isEqual } from 'lodash'; -import { useLicense } from '../../../common/hooks/use_license'; 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'; @@ -140,8 +139,6 @@ const DetectionEnginePageComponent: React.FC = ({ const { loading: listsConfigLoading, needsConfiguration: needsListsConfiguration } = useListsConfig(); - const isPlatinumPlus = useLicense().isPlatinumPlus(); - const [assignees, setAssignees] = useState([]); const handleSelectedAssignees = useCallback( (newAssignees: AssigneesIdsSelection[]) => { @@ -468,14 +465,12 @@ const DetectionEnginePageComponent: React.FC = ({ - {isPlatinumPlus && ( - - - - )} + + + ', () => { }); (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); 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 index 550cbb16e9d7a..7544388a62f96 100644 --- 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 @@ -13,6 +13,7 @@ import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiTitle, EuiToolTip } from ' 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'; @@ -27,27 +28,21 @@ import { ASSIGNEES_TITLE_TEST_ID, } from './test_ids'; -const UpdateAssigneesButton: FC<{ togglePopover: () => void; isDisabled: boolean }> = memo( - ({ togglePopover, isDisabled }) => ( - - - - ) -); +const UpdateAssigneesButton: FC<{ + isDisabled: boolean; + toolTipMessage: string; + togglePopover: () => void; +}> = memo(({ togglePopover, isDisabled, toolTipMessage }) => ( + + + +)); UpdateAssigneesButton.displayName = 'UpdateAssigneesButton'; export interface AssigneesProps { @@ -73,6 +68,7 @@ export interface AssigneesProps { export const Assignees: FC = memo( ({ eventId, assignedUserIds, onAssigneesUpdated }) => { const isPlatinumPlus = useLicense().isPlatinumPlus(); + const upsellingMessage = useUpsellingMessage('alert_assignments'); const { hasIndexWrite } = useAlertsPrivileges(); const setAlertAssignees = useSetAlertAssignees(); @@ -139,6 +135,15 @@ export const Assignees: FC = memo( } isPopoverOpen={isPopoverOpen} 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 8d72041d69739..39846dc62d08d 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 @@ -26,6 +26,7 @@ import { DEFAULT_PREVIEW_INDEX, ASSISTANT_FEATURE_ID, } from '../../../../../common/constants'; +import { useUpsellingMessage } from '../../../../common/hooks/use_upselling'; const ecsData: Ecs = { _id: '1', @@ -128,6 +129,7 @@ jest.mock('../../../../explore/containers/risk_score', () => { }), }; }); +jest.mock('../../../../common/hooks/use_upselling'); const defaultProps = { scopeId: TimelineId.test, @@ -181,6 +183,7 @@ describe('event details panel component', () => { }, }); (useGetUserCasesPermissions as jest.Mock).mockReturnValue(allCasesPermissions()); + (useUpsellingMessage as jest.Mock).mockReturnValue('Go for Platinum!'); }); afterEach(() => { jest.clearAllMocks(); 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/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 index 12881280388a3..690fa2c179a99 100644 --- 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 @@ -5,6 +5,7 @@ * 2.0. */ +import { login } from '../../../../tasks/login'; import { getNewRule } from '../../../../objects/rule'; import { expandFirstAlert } from '../../../../tasks/alerts'; import { createRule } from '../../../../tasks/api_calls/rules'; @@ -20,6 +21,21 @@ import { 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(() => { @@ -31,15 +47,6 @@ describe('Alert user assignment - Basic License', { tags: ['@ess'] }, () => { deleteAlertsAndRules(); createRule(getNewRule({ rule_id: 'new custom rule' })); waitForAlertsToPopulate(); - - cy.request({ - method: 'POST', - url: '/api/license/start_basic?acknowledge=true', - headers: { - 'kbn-xsrf': 'cypress-creds', - 'x-elastic-internal-origin': 'security-solution', - }, - }); }); it('user with Basic license should not be able to update assignees', () => { From 282426844328b47518239ad35d3e9cdc3e8dc5df Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Tue, 28 Nov 2023 12:48:24 +0100 Subject: [PATCH 48/53] Trigger Deployment From 58f77bb6b94f015092744f44b94e783bfdbac409 Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Tue, 28 Nov 2023 17:13:44 +0100 Subject: [PATCH 49/53] Trigger Deployment From f7a2f2da8257ba544eee7ce8b1a1719f490bebab Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Wed, 29 Nov 2023 15:38:18 +0100 Subject: [PATCH 50/53] Add `useGetCurrentUserProfile` hook description --- .../user_profiles/use_get_current_user_profile.tsx | 5 +++++ 1 file changed, 5 insertions(+) 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 index 6c5fb692e4d4a..fbb4bb0660407 100644 --- 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 @@ -22,6 +22,11 @@ export const getCurrentUserProfile = async ({ 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(); From 5f48801edb85217a6471684b50b2001884ccf691 Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Wed, 29 Nov 2023 18:50:33 +0100 Subject: [PATCH 51/53] Trigger Build From 05aba2f963df57e59690fa82de92c3adc2aee1cd Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Thu, 30 Nov 2023 10:44:38 +0100 Subject: [PATCH 52/53] Review feedback --- .../test_plans/detection_response/alerts/user_assignment.md | 1 - 1 file changed, 1 deletion(-) 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 index de92941a45523..a2b360423e5c8 100644 --- 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 @@ -26,7 +26,6 @@ Status: `in progress`. The current test plan covers functionality described in [ - 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 -- The feature is available under the Basic license - 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 From 8a6b03f5dbc69e2b645cce7b12d0de87035a0038 Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Fri, 1 Dec 2023 12:12:20 +0100 Subject: [PATCH 53/53] Specifying the schemas for new APIs with OpenAPI (#172285) ## Summary With these changes we specify the schemas for new alert assignments APIs with OpenAPI. cc @yctercero @marshallmain --- .../detection_engine/alert_assignees/index.ts | 2 +- .../detection_engine/alert_assignees/mocks.ts | 2 +- .../set_alert_assignees_route.ts | 20 ------- .../set_alert_assignees_route.gen.ts | 46 +++++++++++++++ .../set_alert_assignees_route.mock.ts | 2 +- .../set_alert_assignees_route.schema.yaml | 58 +++++++++++++++++++ .../api/detection_engine/model/schemas.ts | 12 +--- .../api/detection_engine/users/index.ts | 2 +- .../suggest_user_profiles_route.ts | 19 ------ .../users/suggest_user_profiles_route.gen.ts | 22 +++++++ .../suggest_user_profiles_route.schema.yaml | 23 ++++++++ .../signals/set_alert_assignees_route.test.ts | 8 ++- .../signals/set_alert_assignees_route.ts | 15 +---- .../users/suggest_user_profiles_route.ts | 11 +--- .../alerts/assignments/assignments.ts | 5 +- 15 files changed, 169 insertions(+), 78 deletions(-) delete mode 100644 x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/set_alert_assignees/set_alert_assignees_route.ts create mode 100644 x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/set_alert_assignees_route.gen.ts rename x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/{set_alert_assignees => }/set_alert_assignees_route.mock.ts (96%) create mode 100644 x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/set_alert_assignees_route.schema.yaml delete mode 100644 x-pack/plugins/security_solution/common/api/detection_engine/users/suggest_user_profiles/suggest_user_profiles_route.ts create mode 100644 x-pack/plugins/security_solution/common/api/detection_engine/users/suggest_user_profiles_route.gen.ts create mode 100644 x-pack/plugins/security_solution/common/api/detection_engine/users/suggest_user_profiles_route.schema.yaml 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 index e0fa0d8eb6408..b74132faed031 100644 --- 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 @@ -5,4 +5,4 @@ * 2.0. */ -export * from './set_alert_assignees/set_alert_assignees_route'; +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 index 15b16eecb2868..ef668dc36d421 100644 --- 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 @@ -5,4 +5,4 @@ * 2.0. */ -export * from './set_alert_assignees/set_alert_assignees_route.mock'; +export * from './set_alert_assignees_route.mock'; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/set_alert_assignees/set_alert_assignees_route.ts b/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/set_alert_assignees/set_alert_assignees_route.ts deleted file mode 100644 index 9a8fd4f052948..0000000000000 --- a/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/set_alert_assignees/set_alert_assignees_route.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import * as t from 'io-ts'; - -import { alert_ids, alert_assignees } from '../../model'; - -export const setAlertAssigneesRequestBody = t.exact( - t.type({ - assignees: alert_assignees, - ids: alert_ids, - }) -); - -export type SetAlertAssigneesRequestBody = t.TypeOf; -export type SetAlertAssigneesRequestBodyDecoded = SetAlertAssigneesRequestBody; 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/set_alert_assignees_route.mock.ts b/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/set_alert_assignees_route.mock.ts similarity index 96% rename from x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/set_alert_assignees/set_alert_assignees_route.mock.ts rename to x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/set_alert_assignees_route.mock.ts index 004619f169f72..9c41e2eae8058 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/set_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 @@ -5,7 +5,7 @@ * 2.0. */ -import type { SetAlertAssigneesRequestBody } from './set_alert_assignees_route'; +import type { SetAlertAssigneesRequestBody } from './set_alert_assignees_route.gen'; export const getSetAlertAssigneesRequestMock = ( assigneesToAdd: string[] = [], 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/model/schemas.ts b/x-pack/plugins/security_solution/common/api/detection_engine/model/schemas.ts index 8691cb5b6ab4e..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 @@ -8,7 +8,7 @@ /* eslint-disable @typescript-eslint/naming-convention */ import * as t from 'io-ts'; -import { NonEmptyArray, NonEmptyString, PositiveInteger } from '@kbn/securitysolution-io-ts-types'; +import { PositiveInteger } from '@kbn/securitysolution-io-ts-types'; export const file_name = t.string; export type FileName = t.TypeOf; @@ -42,9 +42,6 @@ export const signal_status_query = t.object; export const alert_tag_ids = t.array(t.string); export type AlertTagIds = t.TypeOf; -export const alert_ids = NonEmptyArray(NonEmptyString); -export type AlertIds = t.TypeOf; - export const indexRecord = t.record( t.string, t.type({ @@ -111,12 +108,5 @@ export const alert_tags = t.type({ export type AlertTags = t.TypeOf; -export const alert_assignees = t.type({ - add: t.array(NonEmptyString), - remove: t.array(NonEmptyString), -}); - -export type AlertAssignees = 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 index f931f063971a3..b4775b77bf69f 100644 --- 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 @@ -5,4 +5,4 @@ * 2.0. */ -export * from './suggest_user_profiles/suggest_user_profiles_route'; +export * from './suggest_user_profiles_route.gen'; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/users/suggest_user_profiles/suggest_user_profiles_route.ts b/x-pack/plugins/security_solution/common/api/detection_engine/users/suggest_user_profiles/suggest_user_profiles_route.ts deleted file mode 100644 index 12f87860fb002..0000000000000 --- a/x-pack/plugins/security_solution/common/api/detection_engine/users/suggest_user_profiles/suggest_user_profiles_route.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import * as t from 'io-ts'; - -import { user_search_term } from '../../model'; - -export const suggestUserProfilesRequestQuery = t.exact( - t.partial({ - searchTerm: user_search_term, - }) -); - -export type SuggestUserProfilesRequestQuery = t.TypeOf; -export type SuggestUserProfilesRequestQueryDecoded = SuggestUserProfilesRequestQuery; 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/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 index c8b419a6fdbfb..dfc0603598a00 100644 --- 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 @@ -77,7 +77,9 @@ describe('setAlertAssigneesRoute', () => { const result = server.validate(request); - expect(result.badRequest).toHaveBeenCalledWith('Invalid value "[]" supplied to "ids"'); + expect(result.badRequest).toHaveBeenCalledWith( + 'ids: Array must contain at least 1 element(s)' + ); }); test('rejects if empty string provided as an alert id', async () => { @@ -89,7 +91,9 @@ describe('setAlertAssigneesRoute', () => { const result = server.validate(request); - expect(result.badRequest).toHaveBeenCalledWith('Invalid value "" supplied to "ids"'); + expect(result.badRequest).toHaveBeenCalledWith( + 'ids.0: String must contain at least 1 character(s), ids.0: Invalid' + ); }); }); 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 index da2c7d77bb3bf..f15342a36f46c 100644 --- 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 @@ -7,15 +7,14 @@ import { transformError } from '@kbn/securitysolution-es-utils'; import { uniq } from 'lodash/fp'; -import type { SetAlertAssigneesRequestBodyDecoded } from '../../../../../common/api/detection_engine/alert_assignees'; -import { setAlertAssigneesRequestBody } from '../../../../../common/api/detection_engine/alert_assignees'; +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 { buildRouteValidation } from '../../../../utils/build_validation/route_validation'; +import { buildRouteValidationWithZod } from '../../../../utils/build_validation/route_validation'; import { validateAlertAssigneesArrays } from './helpers'; export const setAlertAssigneesRoute = (router: SecuritySolutionPluginRouter) => { @@ -32,10 +31,7 @@ export const setAlertAssigneesRoute = (router: SecuritySolutionPluginRouter) => version: '2023-10-31', validate: { request: { - body: buildRouteValidation< - typeof setAlertAssigneesRequestBody, - SetAlertAssigneesRequestBodyDecoded - >(setAlertAssigneesRequestBody), + body: buildRouteValidationWithZod(SetAlertAssigneesRequestBody), }, }, }, @@ -44,7 +40,6 @@ export const setAlertAssigneesRoute = (router: SecuritySolutionPluginRouter) => const core = await context.core; const securitySolution = await context.securitySolution; const esClient = core.elasticsearch.client.asCurrentUser; - const siemClient = securitySolution?.getAppClient(); const siemResponse = buildSiemResponse(response); const validationErrors = validateAlertAssigneesArrays(assignees); const spaceId = securitySolution?.getSpaceId() ?? 'default'; @@ -53,10 +48,6 @@ export const setAlertAssigneesRoute = (router: SecuritySolutionPluginRouter) => return siemResponse.error({ statusCode: 400, body: validationErrors }); } - if (!siemClient) { - return siemResponse.error({ statusCode: 404 }); - } - const assigneesToAdd = uniq(assignees.add); const assigneesToRemove = uniq(assignees.remove); 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 index 6b48dfcf84380..fcb42d2ead7e4 100644 --- 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 @@ -12,10 +12,8 @@ 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 { buildRouteValidation } from '../../../../utils/build_validation/route_validation'; - -import type { SuggestUserProfilesRequestQueryDecoded } from '../../../../../common/api/detection_engine/users'; -import { suggestUserProfilesRequestQuery } from '../../../../../common/api/detection_engine/users'; +import { buildRouteValidationWithZod } from '../../../../utils/build_validation/route_validation'; +import { SuggestUserProfilesRequestQuery } from '../../../../../common/api/detection_engine/users'; export const suggestUserProfilesRoute = ( router: SecuritySolutionPluginRouter, @@ -34,10 +32,7 @@ export const suggestUserProfilesRoute = ( version: '2023-10-31', validate: { request: { - query: buildRouteValidation< - typeof suggestUserProfilesRequestQuery, - SuggestUserProfilesRequestQueryDecoded - >(suggestUserProfilesRequestQuery), + query: buildRouteValidationWithZod(SuggestUserProfilesRequestQuery), }, }, }, 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 index 9f4557217e0e9..b520b505e0405 100644 --- 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 @@ -50,7 +50,7 @@ export default ({ getService }: FtrProviderContext) => { expect(body).to.eql({ error: 'Bad Request', - message: '[request body]: Invalid value "[]" supplied to "ids"', + message: '[request body]: ids: Array must contain at least 1 element(s)', statusCode: 400, }); }); @@ -64,7 +64,8 @@ export default ({ getService }: FtrProviderContext) => { expect(body).to.eql({ error: 'Bad Request', - message: '[request body]: Invalid value "" supplied to "ids"', + message: + '[request body]: ids.1: String must contain at least 1 character(s), ids.1: Invalid', statusCode: 400, }); });