[Security Solution][Alerts] Alert (+Investigation) User Assignment (#2504) (#170579)

## Summary

With this PR we introduce a new Alert User Assignment feature:
- It is possible to assign a user/s to alert/s
- There is a new "Assignees" column in the alerts table which displays
avatars of assigned users
- There is a bulk action to update assignees for multiple alerts
- It is possible to see and update assignees inside the alert details
flyout component
- There is an "Assignees" filter button on the Alerts page which allows
to filter alerts by assignees

We decided to develop this feature on a separate branch. This gives us
ability to make sure that it is thoroughly tested and we did not break
anything in production. Since there is a data scheme changes involved we
decided that it will be a better approach. cc @yctercero

## Testing notes

In order to test assignments you need to create a few users. Then for
users to appear in user profiles dropdown menu you need to activate them
by login into those account at least once.


8eeb13f3-2d16-4fba-acdf-755024a59fc2

Main ticket https://github.com/elastic/security-team/issues/2504

## Bugfixes
- [x] https://github.com/elastic/security-team/issues/8028
- [x] https://github.com/elastic/security-team/issues/8034
- [x] https://github.com/elastic/security-team/issues/8006
- [x] https://github.com/elastic/security-team/issues/8025

## Enhancements
- [x] https://github.com/elastic/security-team/issues/8033

### Checklist

- [x] Functional changes are hidden behind a feature flag. If not
hidden, the PR explains why these changes are being implemented in a
long-living feature branch.
- [x] Functional changes are covered with a test plan and automated
tests.
  - [x] https://github.com/elastic/kibana/issues/171306
  - [x] https://github.com/elastic/kibana/issues/171307
- [x] Stability of new and changed tests is verified using the [Flaky
Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner).
- [x]
https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/4091
- [x] Comprehensive manual testing is done by two engineers: the PR
author and one of the PR reviewers. Changes are tested in both ESS and
Serverless.
- [x] Mapping changes are accompanied by a technical design document. It
can be a GitHub issue or an RFC explaining the changes. The design
document is shared with and approved by the appropriate teams and
individual stakeholders.
   * https://github.com/elastic/security-team/issues/7647
- [x] Functional changes are communicated to the Docs team. A ticket or
PR is opened in https://github.com/elastic/security-docs. The following
information is included: any feature flags used, affected environments
(Serverless, ESS, or both). **NOTE: as discussed we will wait until docs
are ready to merge this PR**.
   * https://github.com/elastic/security-docs/issues/4226
* https://github.com/elastic/staging-serverless-security-docs/pull/232

---------

Co-authored-by: Marshall Main <marshall.main@elastic.co>
Co-authored-by: Xavier Mouligneau <xavier.mouligneau@elastic.co>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Sergi Massaneda <sergi.massaneda@gmail.com>
This commit is contained in:
Ievgen Sorokopud 2023-12-01 16:26:03 +01:00 committed by GitHub
parent d24d43c7c1
commit 1ebdbc380d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
137 changed files with 6337 additions and 218 deletions

View file

@ -32,6 +32,7 @@ import {
ALERT_TIME_RANGE,
ALERT_URL,
ALERT_UUID,
ALERT_WORKFLOW_ASSIGNEE_IDS,
ALERT_WORKFLOW_STATUS,
ALERT_WORKFLOW_TAGS,
SPACE_IDS,
@ -190,6 +191,11 @@ export const alertFieldMap = {
array: true,
required: false,
},
[ALERT_WORKFLOW_ASSIGNEE_IDS]: {
type: 'keyword',
array: true,
required: false,
},
[EVENT_ACTION]: {
type: 'keyword',
array: false,

View file

@ -98,6 +98,7 @@ const AlertOptional = rt.partial({
'kibana.alert.start': schemaDate,
'kibana.alert.time_range': schemaDateRange,
'kibana.alert.url': schemaString,
'kibana.alert.workflow_assignee_ids': schemaStringArray,
'kibana.alert.workflow_status': schemaString,
'kibana.alert.workflow_tags': schemaStringArray,
'kibana.version': schemaString,

View file

@ -193,6 +193,7 @@ const SecurityAlertOptional = rt.partial({
),
'kibana.alert.time_range': schemaDateRange,
'kibana.alert.url': schemaString,
'kibana.alert.workflow_assignee_ids': schemaStringArray,
'kibana.alert.workflow_reason': schemaString,
'kibana.alert.workflow_status': schemaString,
'kibana.alert.workflow_tags': schemaStringArray,

View file

@ -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',

View file

@ -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,

View file

@ -32,6 +32,7 @@ import {
ALERT_STATUS,
ALERT_TIME_RANGE,
ALERT_UUID,
ALERT_WORKFLOW_ASSIGNEE_IDS,
ALERT_WORKFLOW_STATUS,
ALERT_WORKFLOW_TAGS,
SPACE_IDS,
@ -174,6 +175,7 @@ const fields = {
ALERT_STATUS,
ALERT_SYSTEM_STATUS,
ALERT_UUID,
ALERT_WORKFLOW_ASSIGNEE_IDS,
ALERT_WORKFLOW_REASON,
ALERT_WORKFLOW_STATUS,
ALERT_WORKFLOW_TAGS,

View file

@ -24,6 +24,7 @@ export type SignalEcsAAD = Exclude<SignalEcs, 'rule' | 'status'> & {
building_block_type?: string[];
workflow_status?: string[];
workflow_tags?: string[];
workflow_assignee_ids?: string[];
suppression?: {
docs_count: string[];
};

View file

@ -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,
},
});

View file

@ -17,4 +17,4 @@ export type UpsellingSectionId =
| 'osquery_automated_response_actions'
| 'ruleDetailsEndpointExceptions';
export type UpsellingMessageId = 'investigation_guide';
export type UpsellingMessageId = 'investigation_guide' | 'alert_assignments';

View file

@ -311,6 +311,9 @@ describe('mappingFromFieldMap', () => {
workflow_tags: {
type: 'keyword',
},
workflow_assignee_ids: {
type: 'keyword',
},
},
},
space_ids: {

View file

@ -293,6 +293,11 @@ it('matches snapshot', () => {
"required": true,
"type": "keyword",
},
"kibana.alert.workflow_assignee_ids": Object {
"array": true,
"required": false,
"type": "keyword",
},
"kibana.alert.workflow_reason": Object {
"array": false,
"required": false,

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './set_alert_assignees_route.gen';

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './set_alert_assignees_route.mock';

View file

@ -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<typeof AlertAssignees>;
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<typeof AlertIds>;
export const AlertIds = z.array(NonEmptyString).min(1);
export type SetAlertAssigneesRequestBody = z.infer<typeof SetAlertAssigneesRequestBody>;
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<typeof SetAlertAssigneesRequestBody>;

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { SetAlertAssigneesRequestBody } from './set_alert_assignees_route.gen';
export const getSetAlertAssigneesRequestMock = (
assigneesToAdd: string[] = [],
assigneesToRemove: string[] = [],
ids: string[] = []
): SetAlertAssigneesRequestBody => ({
assignees: { add: assigneesToAdd, remove: assigneesToRemove },
ids,
});

View file

@ -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.

View file

@ -5,6 +5,7 @@
* 2.0.
*/
export * from './alert_assignees';
export * from './alert_tags';
export * from './fleet_integrations';
export * from './index_management';

View file

@ -0,0 +1,56 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils';
import type { AlertWithCommonFields800 } from '@kbn/rule-registry-plugin/common/schemas/8.0.0';
import type {
Ancestor890,
BaseFields890,
EqlBuildingBlockFields890,
EqlShellFields890,
NewTermsFields890,
} from '../8.9.0';
/* DO NOT MODIFY THIS SCHEMA TO ADD NEW FIELDS. These types represent the alerts that shipped in 8.12.0.
Any changes to these types should be bug fixes so the types more accurately represent the alerts from 8.12.0.
If you are adding new fields for a new release of Kibana, create a new sibling folder to this one
for the version to be released and add the field(s) to the schema in that folder.
Then, update `../index.ts` to import from the new folder that has the latest schemas, add the
new schemas to the union of all alert schemas, and re-export the new schemas as the `*Latest` schemas.
*/
export type { Ancestor890 as Ancestor8120 };
export interface BaseFields8120 extends BaseFields890 {
[ALERT_WORKFLOW_ASSIGNEE_IDS]: string[] | undefined;
}
export interface WrappedFields8120<T extends BaseFields8120> {
_id: string;
_index: string;
_source: T;
}
export type GenericAlert8120 = AlertWithCommonFields800<BaseFields8120>;
export type EqlShellFields8120 = EqlShellFields890 & BaseFields8120;
export type EqlBuildingBlockFields8120 = EqlBuildingBlockFields890 & BaseFields8120;
export type NewTermsFields8120 = NewTermsFields890 & BaseFields8120;
export type NewTermsAlert8120 = NewTermsFields890 & BaseFields8120;
export type EqlBuildingBlockAlert8120 = AlertWithCommonFields800<EqlBuildingBlockFields890>;
export type EqlShellAlert8120 = AlertWithCommonFields800<EqlShellFields8120>;
export type DetectionAlert8120 =
| GenericAlert8120
| EqlShellAlert8120
| EqlBuildingBlockAlert8120
| NewTermsAlert8120;

View file

@ -11,15 +11,16 @@ import type { DetectionAlert840 } from './8.4.0';
import type { DetectionAlert860 } from './8.6.0';
import type { DetectionAlert870 } from './8.7.0';
import type { DetectionAlert880 } from './8.8.0';
import type { DetectionAlert890 } from './8.9.0';
import type {
Ancestor890,
BaseFields890,
DetectionAlert890,
EqlBuildingBlockFields890,
EqlShellFields890,
NewTermsFields890,
WrappedFields890,
} from './8.9.0';
Ancestor8120,
BaseFields8120,
DetectionAlert8120,
EqlBuildingBlockFields8120,
EqlShellFields8120,
NewTermsFields8120,
WrappedFields8120,
} from './8.12.0';
// When new Alert schemas are created for new Kibana versions, add the DetectionAlert type from the new version
// here, e.g. `export type DetectionAlert = DetectionAlert800 | DetectionAlert820` if a new schema is created in 8.2.0
@ -29,14 +30,15 @@ export type DetectionAlert =
| DetectionAlert860
| DetectionAlert870
| DetectionAlert880
| DetectionAlert890;
| DetectionAlert890
| DetectionAlert8120;
export type {
Ancestor890 as AncestorLatest,
BaseFields890 as BaseFieldsLatest,
DetectionAlert890 as DetectionAlertLatest,
WrappedFields890 as WrappedFieldsLatest,
EqlBuildingBlockFields890 as EqlBuildingBlockFieldsLatest,
EqlShellFields890 as EqlShellFieldsLatest,
NewTermsFields890 as NewTermsFieldsLatest,
Ancestor8120 as AncestorLatest,
BaseFields8120 as BaseFieldsLatest,
DetectionAlert8120 as DetectionAlertLatest,
WrappedFields8120 as WrappedFieldsLatest,
EqlBuildingBlockFields8120 as EqlBuildingBlockFieldsLatest,
EqlShellFields8120 as EqlShellFieldsLatest,
NewTermsFields8120 as NewTermsFieldsLatest,
};

View file

@ -107,3 +107,6 @@ export const alert_tags = t.type({
});
export type AlertTags = t.TypeOf<typeof alert_tags>;
export const user_search_term = t.string;
export type UserSearchTerm = t.TypeOf<typeof user_search_term>;

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './suggest_user_profiles_route.gen';

View file

@ -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<typeof SuggestUserProfilesRequestQuery>;
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<typeof SuggestUserProfilesRequestQuery>;

View file

@ -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.

View file

@ -319,6 +319,10 @@ export const DETECTION_ENGINE_SIGNALS_MIGRATION_STATUS_URL =
export const DETECTION_ENGINE_SIGNALS_FINALIZE_MIGRATION_URL =
`${DETECTION_ENGINE_SIGNALS_URL}/finalize_migration` as const;
export const DETECTION_ENGINE_ALERT_TAGS_URL = `${DETECTION_ENGINE_SIGNALS_URL}/tags` as const;
export const DETECTION_ENGINE_ALERT_ASSIGNEES_URL =
`${DETECTION_ENGINE_SIGNALS_URL}/assignees` as const;
export const DETECTION_ENGINE_ALERT_SUGGEST_USERS_URL =
`${DETECTION_ENGINE_SIGNALS_URL}/_find` as const;
export const ALERTS_AS_DATA_URL = '/internal/rac/alerts' as const;
export const ALERTS_AS_DATA_FIND_URL = `${ALERTS_AS_DATA_URL}/find` as const;

View file

@ -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;
};

View file

@ -0,0 +1,216 @@
# Alert User Assignment
This is a test plan for the Alert User Assignment feature
Status: `in progress`. The current test plan covers functionality described in [Alert User Assignment](https://github.com/elastic/security-team/issues/2504) epic.
## Useful information
### Tickets
- [Alert User Assignment](https://github.com/elastic/security-team/issues/2504) epic
- [Add test coverage for Alert User Assignment](https://github.com/elastic/kibana/issues/171307)
- [Write a test plan for Alert User Assignment](https://github.com/elastic/kibana/issues/171306)
### Terminology
- **Assignee**: The user assigned to an alert.
- **Assignees field**: The alert's `kibana.alert.workflow_assignee_ids` field which contains an array of assignees IDs. These ids conrespond to [User Profiles](https://www.elastic.co/guide/en/elasticsearch/reference/current/user-profile.html) endpoint.
- **Assignee's avatar**: The avatar of an assignee. Can be either user profile picture if uploaded by the user or initials of the user.
- **Assignees count badge**: The badge with the number of assignees.
### Assumptions
- The feature is **NOT** available under the Basic license
- Assignees are stored as an array of users IDs in alert's `kibana.alert.workflow_assignee_ids` field
- There are multiple (five or more) available users which could be assigned to alerts
- User need to have editor or higher privileges to assign users to alerts
- Mixed states are not supported by the current version of User Profiles component
- "Displayed/Shown in UI" refers to "Alerts Table" and "Alert's Details Flyout"
## Scenarios
### Basic rendering
#### **Scenario: No assignees**
**Automation**: 2 e2e test + 2 unit test.
```Gherkin
Given an alert doesn't have assignees
Then no assignees' (represented by avatars) should be displayed in UI
```
#### **Scenario: With assignees**
**Automation**: 2 e2e test + 2 unit test.
```Gherkin
Given an alert has assignees
Then assignees' (represented by avatars) for each assignee should be shown in UI
```
#### **Scenario: Many assignees (Badge)**
**Automation**: 2 e2e test + 2 unit test.
```Gherkin
Given an alert has more assignees than maximum number allowed to display
Then assignees count badge is displayed in UI
```
### Updating assignees (single alert)
#### **Scenario: Add new assignees**
**Automation**: 3 e2e test + 1 unit test + 1 integration test.
```Gherkin
Given an alert
When user adds new assignees
Then assignees field should be updated
And newly added assignees should be present
```
#### **Scenario: Update assignees**
**Automation**: 3 e2e test + 1 unit test + 1 integration test.
```Gherkin
Given an alert with assignees
When user removes some of (or all) current assignees and adds new assignees
Then assignees field should be updated
And removed assignees should be absent
And newly added assignees should be present
```
#### **Scenario: Unassign alert**
**Automation**: 2 e2e test + 1 unit test.
```Gherkin
Given an alert with assignees
When user triggers "Unassign alert" action
Then assignees field should be updated
And assignees field should be empty
```
### Updating assignees (bulk actions)
#### **Scenario: Add new assignees**
**Automation**: 1 e2e test + 1 unit test + 1 integration test.
```Gherkin
Given multiple alerts
When user adds new assignees
Then assignees fields of all involved alerts should be updated
And newly added assignees should be present
```
#### **Scenario: Update assignees**
**Automation**: 1 e2e test + 1 unit test + 1 integration test.
```Gherkin
Given multiple alerts with assignees
When user removes some of (or all) current assignees and adds new assignees
Then assignees fields of all involved alerts should be updated
And removed assignees should be absent
And newly added assignees should be present
```
#### **Scenario: Unassign alert**
**Automation**: 1 e2e test + 1 unit test.
```Gherkin
Given multiple alerts with assignees
When user triggers "Unassign alert" action
Then assignees fields of all involved alerts should be updated
And assignees fields should be empty
```
### Alerts filtering
#### **Scenario: By one assignee**
**Automation**: 1 e2e test + 1 unit test.
```Gherkin
Given multiple alerts with and without assignees
When user filters by one of the assignees
Then only alerts with selected assignee in assignees field are displayed
```
#### **Scenario: By multiple assignees**
**Automation**: 1 e2e test + 1 unit test.
```Gherkin
Given multiple alerts with and without assignees
When user filters by multiple assignees
Then all alerts with either of selected assignees in assignees fields are displayed
```
#### **Scenario: "No assignees" option**
**Automation**: 1 e2e test + 1 unit test.
```Gherkin
Given filter by assignees UI is available
Then there should be an option to filter alerts to see those which are not assigned to anyone
```
#### **Scenario: By "No assignees"**
**Automation**: 1 e2e test + 1 unit test.
```Gherkin
Given multiple alerts with and without assignees
When user filters by "No assignees" option
Then all alerts with empty assignees fields are displayed
```
#### **Scenario: By assignee and alert status**
**Automation**: 1 e2e test + 1 unit test.
```Gherkin
Given multiple alerts with and without assignees
When user filters by one of the assignees
AND alert's status
Then only alerts with selected assignee in assignees field AND selected alert's status are displayed
```
### Authorization / RBAC
#### **Scenario: Viewer role**
**Automation**: 1 e2e test + 1 unit test + 1 integration test.
```Gherkin
Given user has "viewer/readonly" role
Then there should not be a way to update assignees field for an alert
```
#### **Scenario: Serverless roles**
**Automation**: 1 e2e test + 1 unit test + 1 integration test.
```Gherkin
Given users 't1_analyst', 't2_analyst', 't3_analyst', 'rule_author', 'soc_manager', 'detections_admin', 'platform_engineer' roles
Then update assignees functionality should be available
```
#### **Scenario: Basic license**
**Automation**: 1 e2e test + 1 unit test + 1 integration test.
```Gherkin
Given user runs Kibana under the Basic license
Then update assignees functionality should not be available
```

View file

@ -0,0 +1,139 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { ASSIGNEES_APPLY_BUTTON_TEST_ID, ASSIGNEES_APPLY_PANEL_TEST_ID } from './test_ids';
import { AssigneesApplyPanel } from './assignees_apply_panel';
import { useGetCurrentUserProfile } from '../user_profiles/use_get_current_user_profile';
import { useBulkGetUserProfiles } from '../user_profiles/use_bulk_get_user_profiles';
import { useSuggestUsers } from '../user_profiles/use_suggest_users';
import { TestProviders } from '../../mock';
import * as i18n from './translations';
import { mockUserProfiles } from './mocks';
jest.mock('../user_profiles/use_get_current_user_profile');
jest.mock('../user_profiles/use_bulk_get_user_profiles');
jest.mock('../user_profiles/use_suggest_users');
const renderAssigneesApplyPanel = (
{
assignedUserIds,
showUnassignedOption,
onSelectionChange,
onAssigneesApply,
}: {
assignedUserIds: string[];
showUnassignedOption?: boolean;
onSelectionChange?: () => void;
onAssigneesApply?: () => void;
} = { assignedUserIds: [] }
) => {
const assignedProfiles = mockUserProfiles.filter((user) => assignedUserIds.includes(user.uid));
(useBulkGetUserProfiles as jest.Mock).mockReturnValue({
isLoading: false,
data: assignedProfiles,
});
return render(
<TestProviders>
<AssigneesApplyPanel
assignedUserIds={assignedUserIds}
showUnassignedOption={showUnassignedOption}
onSelectionChange={onSelectionChange}
onAssigneesApply={onAssigneesApply}
/>
</TestProviders>
);
};
describe('<AssigneesApplyPanel />', () => {
beforeEach(() => {
jest.clearAllMocks();
(useGetCurrentUserProfile as jest.Mock).mockReturnValue({
isLoading: false,
data: mockUserProfiles[0],
});
(useSuggestUsers as jest.Mock).mockReturnValue({
isLoading: false,
data: mockUserProfiles,
});
});
it('should render component', () => {
const { getByTestId, queryByTestId } = renderAssigneesApplyPanel();
expect(getByTestId(ASSIGNEES_APPLY_PANEL_TEST_ID)).toBeInTheDocument();
expect(queryByTestId(ASSIGNEES_APPLY_BUTTON_TEST_ID)).not.toBeInTheDocument();
});
it('should render apply button if `onAssigneesApply` callback provided', () => {
const { getByTestId } = renderAssigneesApplyPanel({
assignedUserIds: [],
onAssigneesApply: jest.fn(),
});
expect(getByTestId(ASSIGNEES_APPLY_PANEL_TEST_ID)).toBeInTheDocument();
expect(getByTestId(ASSIGNEES_APPLY_BUTTON_TEST_ID)).toBeInTheDocument();
});
it('should render `no assignees` option', () => {
const { getByTestId } = renderAssigneesApplyPanel({
assignedUserIds: [],
showUnassignedOption: true,
onAssigneesApply: jest.fn(),
});
const assigneesList = getByTestId('euiSelectableList');
expect(assigneesList).toHaveTextContent(i18n.ASSIGNEES_NO_ASSIGNEES);
});
it('should call `onAssigneesApply` on apply button click', () => {
const onAssigneesApplyMock = jest.fn();
const { getByText, getByTestId } = renderAssigneesApplyPanel({
assignedUserIds: ['user-id-1'],
onAssigneesApply: onAssigneesApplyMock,
});
getByText(mockUserProfiles[1].user.full_name).click();
getByTestId(ASSIGNEES_APPLY_BUTTON_TEST_ID).click();
expect(onAssigneesApplyMock).toHaveBeenCalledTimes(1);
expect(onAssigneesApplyMock).toHaveBeenLastCalledWith(['user-id-2', 'user-id-1']);
});
it('should call `onSelectionChange` on user selection', () => {
(useBulkGetUserProfiles as jest.Mock).mockReturnValue({
isLoading: false,
data: [],
});
const onSelectionChangeMock = jest.fn();
const { getByText } = renderAssigneesApplyPanel({
assignedUserIds: [],
onSelectionChange: onSelectionChangeMock,
});
getByText('User 1').click();
getByText('User 2').click();
getByText('User 3').click();
getByText('User 3').click();
getByText('User 2').click();
getByText('User 1').click();
expect(onSelectionChangeMock).toHaveBeenCalledTimes(6);
expect(onSelectionChangeMock.mock.calls).toEqual([
[['user-id-1']],
[['user-id-2', 'user-id-1']],
[['user-id-3', 'user-id-2', 'user-id-1']],
[['user-id-2', 'user-id-1']],
[['user-id-1']],
[[]],
]);
});
});

View file

@ -0,0 +1,156 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { isEqual } from 'lodash/fp';
import type { FC } from 'react';
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { EuiButton } from '@elastic/eui';
import { UserProfilesSelectable } from '@kbn/user-profile-components';
import { isEmpty } from 'lodash';
import { useGetCurrentUserProfile } from '../user_profiles/use_get_current_user_profile';
import * as i18n from './translations';
import type { AssigneesIdsSelection, AssigneesProfilesSelection } from './types';
import { NO_ASSIGNEES_VALUE } from './constants';
import { useSuggestUsers } from '../user_profiles/use_suggest_users';
import { useBulkGetUserProfiles } from '../user_profiles/use_bulk_get_user_profiles';
import { bringCurrentUserToFrontAndSort, removeNoAssigneesSelection } from './utils';
import { ASSIGNEES_APPLY_BUTTON_TEST_ID, ASSIGNEES_APPLY_PANEL_TEST_ID } from './test_ids';
export interface AssigneesApplyPanelProps {
/**
* Identifier of search field.
*/
searchInputId?: string;
/**
* Ids of the users assigned to the alert
*/
assignedUserIds: AssigneesIdsSelection[];
/**
* Show "Unassigned" option if needed
*/
showUnassignedOption?: boolean;
/**
* Callback to handle changing of the assignees selection
*/
onSelectionChange?: (users: AssigneesIdsSelection[]) => void;
/**
* Callback to handle applying assignees. If provided will show "Apply assignees" button
*/
onAssigneesApply?: (selectedAssignees: AssigneesIdsSelection[]) => void;
}
/**
* The popover to allow selection of users from a list
*/
export const AssigneesApplyPanel: FC<AssigneesApplyPanelProps> = memo(
({
searchInputId,
assignedUserIds,
showUnassignedOption,
onSelectionChange,
onAssigneesApply,
}) => {
const { data: currentUserProfile } = useGetCurrentUserProfile();
const existingIds = useMemo(
() => new Set(removeNoAssigneesSelection(assignedUserIds)),
[assignedUserIds]
);
const { isLoading: isLoadingAssignedUsers, data: assignedUsers } = useBulkGetUserProfiles({
uids: existingIds,
});
const [searchTerm, setSearchTerm] = useState('');
const { isLoading: isLoadingSuggestedUsers, data: userProfiles } = useSuggestUsers({
searchTerm,
});
const searchResultProfiles = useMemo(() => {
const sortedUsers = bringCurrentUserToFrontAndSort(currentUserProfile, userProfiles) ?? [];
if (showUnassignedOption && isEmpty(searchTerm)) {
return [NO_ASSIGNEES_VALUE, ...sortedUsers];
}
return sortedUsers;
}, [currentUserProfile, searchTerm, showUnassignedOption, userProfiles]);
const [selectedAssignees, setSelectedAssignees] = useState<AssigneesProfilesSelection[]>([]);
useEffect(() => {
if (isLoadingAssignedUsers || !assignedUsers) {
return;
}
const hasNoAssigneesSelection = assignedUserIds.find((uid) => uid === NO_ASSIGNEES_VALUE);
const newAssignees =
hasNoAssigneesSelection !== undefined
? [NO_ASSIGNEES_VALUE, ...assignedUsers]
: assignedUsers;
setSelectedAssignees(newAssignees);
}, [assignedUserIds, assignedUsers, isLoadingAssignedUsers]);
const handleSelectedAssignees = useCallback(
(newAssignees: AssigneesProfilesSelection[]) => {
if (!isEqual(newAssignees, selectedAssignees)) {
setSelectedAssignees(newAssignees);
onSelectionChange?.(newAssignees.map((assignee) => assignee?.uid ?? NO_ASSIGNEES_VALUE));
}
},
[onSelectionChange, selectedAssignees]
);
const handleApplyButtonClick = useCallback(() => {
const selectedIds = selectedAssignees.map((assignee) => assignee?.uid ?? NO_ASSIGNEES_VALUE);
onAssigneesApply?.(selectedIds);
}, [onAssigneesApply, selectedAssignees]);
const selectedStatusMessage = useCallback(
(total: number) => i18n.ASSIGNEES_SELECTION_STATUS_MESSAGE(total),
[]
);
const isLoading = isLoadingAssignedUsers || isLoadingSuggestedUsers;
return (
<div data-test-subj={ASSIGNEES_APPLY_PANEL_TEST_ID}>
<UserProfilesSelectable
searchInputId={searchInputId}
onSearchChange={(term: string) => {
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 && (
<EuiButton
data-test-subj={ASSIGNEES_APPLY_BUTTON_TEST_ID}
fullWidth
size="s"
onClick={handleApplyButtonClick}
isDisabled={isLoading}
>
{i18n.ASSIGNEES_APPLY_BUTTON}
</EuiButton>
)}
</div>
);
}
);
AssigneesApplyPanel.displayName = 'AssigneesPanel';

View file

@ -0,0 +1,98 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { ASSIGNEES_APPLY_PANEL_TEST_ID } from './test_ids';
import { AssigneesPopover } from './assignees_popover';
import { useGetCurrentUserProfile } from '../user_profiles/use_get_current_user_profile';
import { useBulkGetUserProfiles } from '../user_profiles/use_bulk_get_user_profiles';
import { useSuggestUsers } from '../user_profiles/use_suggest_users';
import { TestProviders } from '../../mock';
import { mockUserProfiles } from './mocks';
import { EuiButton } from '@elastic/eui';
jest.mock('../user_profiles/use_get_current_user_profile');
jest.mock('../user_profiles/use_bulk_get_user_profiles');
jest.mock('../user_profiles/use_suggest_users');
const MOCK_BUTTON_TEST_ID = 'mock-assignees-button';
const renderAssigneesPopover = ({
assignedUserIds,
isPopoverOpen,
}: {
assignedUserIds: string[];
isPopoverOpen: boolean;
}) => {
const assignedProfiles = mockUserProfiles.filter((user) => assignedUserIds.includes(user.uid));
(useBulkGetUserProfiles as jest.Mock).mockReturnValue({
isLoading: false,
data: assignedProfiles,
});
return render(
<TestProviders>
<AssigneesPopover
assignedUserIds={assignedUserIds}
button={<EuiButton data-test-subj={MOCK_BUTTON_TEST_ID} />}
isPopoverOpen={isPopoverOpen}
closePopover={jest.fn()}
/>
</TestProviders>
);
};
describe('<AssigneesPopover />', () => {
beforeEach(() => {
jest.clearAllMocks();
(useGetCurrentUserProfile as jest.Mock).mockReturnValue({
isLoading: false,
data: mockUserProfiles[0],
});
(useSuggestUsers as jest.Mock).mockReturnValue({
isLoading: false,
data: mockUserProfiles,
});
});
it('should render closed popover component', () => {
const { getByTestId, queryByTestId } = renderAssigneesPopover({
assignedUserIds: [],
isPopoverOpen: false,
});
expect(getByTestId(MOCK_BUTTON_TEST_ID)).toBeInTheDocument();
expect(queryByTestId(ASSIGNEES_APPLY_PANEL_TEST_ID)).not.toBeInTheDocument();
});
it('should render opened popover component', () => {
const { getByTestId } = renderAssigneesPopover({
assignedUserIds: [],
isPopoverOpen: true,
});
expect(getByTestId(MOCK_BUTTON_TEST_ID)).toBeInTheDocument();
expect(getByTestId(ASSIGNEES_APPLY_PANEL_TEST_ID)).toBeInTheDocument();
});
it('should render assignees', () => {
const { getByTestId } = renderAssigneesPopover({
assignedUserIds: [],
isPopoverOpen: true,
});
const assigneesList = getByTestId('euiSelectableList');
expect(assigneesList).toHaveTextContent('User 1');
expect(assigneesList).toHaveTextContent('user1@test.com');
expect(assigneesList).toHaveTextContent('User 2');
expect(assigneesList).toHaveTextContent('user2@test.com');
expect(assigneesList).toHaveTextContent('User 3');
expect(assigneesList).toHaveTextContent('user3@test.com');
});
});

View file

@ -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<ReactNode>;
/**
* Boolean to allow popover to be opened or closed
*/
isPopoverOpen: boolean;
/**
* Callback to handle hiding of the popover
*/
closePopover: () => void;
/**
* Callback to handle changing of the assignees selection
*/
onSelectionChange?: (users: AssigneesIdsSelection[]) => void;
/**
* Callback to handle applying assignees
*/
onAssigneesApply?: (selectedAssignees: AssigneesIdsSelection[]) => void;
}
/**
* The popover to allow selection of users from a list
*/
export const AssigneesPopover: FC<AssigneesPopoverProps> = memo(
({
assignedUserIds,
showUnassignedOption,
button,
isPopoverOpen,
closePopover,
onSelectionChange,
onAssigneesApply,
}) => {
const searchInputId = useGeneratedHtmlId({
prefix: 'searchInput',
});
return (
<EuiPopover
panelPaddingSize="none"
initialFocus={`#${searchInputId}`}
button={button}
isOpen={isPopoverOpen}
panelStyle={{
minWidth: ASSIGNEES_PANEL_WIDTH,
}}
closePopover={closePopover}
>
<AssigneesApplyPanel
searchInputId={searchInputId}
assignedUserIds={assignedUserIds}
showUnassignedOption={showUnassignedOption}
onSelectionChange={onSelectionChange}
onAssigneesApply={onAssigneesApply}
/>
</EuiPopover>
);
}
);
AssigneesPopover.displayName = 'AssigneesPopover';

View file

@ -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;

View file

@ -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: {},
},
];

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
const PREFIX = 'securitySolutionAssignees';
/* Apply Panel */
export const ASSIGNEES_APPLY_PANEL_TEST_ID = `${PREFIX}ApplyPanel`;
export const ASSIGNEES_APPLY_BUTTON_TEST_ID = `${PREFIX}ApplyButton`;

View file

@ -0,0 +1,42 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const ASSIGNEES_SELECTION_STATUS_MESSAGE = (total: number) =>
i18n.translate('xpack.securitySolution.assignees.totalUsersAssigned', {
defaultMessage: '{total, plural, one {# filter} other {# filters}} selected',
values: { total },
});
export const ASSIGNEES_APPLY_BUTTON = i18n.translate(
'xpack.securitySolution.assignees.applyButtonTitle',
{
defaultMessage: 'Apply',
}
);
export const ASSIGNEES_SEARCH_USERS = i18n.translate(
'xpack.securitySolution.assignees.selectableSearchPlaceholder',
{
defaultMessage: 'Search users',
}
);
export const ASSIGNEES_CLEAR_FILTERS = i18n.translate(
'xpack.securitySolution.assignees.clearFilters',
{
defaultMessage: 'Clear filters',
}
);
export const ASSIGNEES_NO_ASSIGNEES = i18n.translate(
'xpack.securitySolution.assignees.noAssigneesLabel',
{
defaultMessage: 'No assignees',
}
);

View file

@ -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;

View file

@ -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]]);
});
});
});

View file

@ -0,0 +1,59 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { sortBy } from 'lodash';
import type { UserProfileWithAvatar } from '@kbn/user-profile-components';
import { NO_ASSIGNEES_VALUE } from './constants';
import type { AssigneesIdsSelection } from './types';
const getSortField = (profile: UserProfileWithAvatar) =>
profile.user?.full_name?.toLowerCase() ??
profile.user?.email?.toLowerCase() ??
profile.user?.username.toLowerCase();
const sortProfiles = (profiles?: UserProfileWithAvatar[]) => {
if (!profiles) {
return;
}
return sortBy(profiles, getSortField);
};
const moveCurrentUserToBeginning = <T extends { uid: string }>(
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<string>((assignee): assignee is string => assignee !== NO_ASSIGNEES_VALUE);

View file

@ -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<AddOptionsListControlProps> = {

View file

@ -0,0 +1,132 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { FilterByAssigneesPopover } from './filter_by_assignees';
import { TEST_IDS } from './constants';
import { TestProviders } from '../../mock';
import type { AssigneesIdsSelection } from '../assignees/types';
import { useGetCurrentUserProfile } from '../user_profiles/use_get_current_user_profile';
import { useBulkGetUserProfiles } from '../user_profiles/use_bulk_get_user_profiles';
import { useSuggestUsers } from '../user_profiles/use_suggest_users';
import { useLicense } from '../../hooks/use_license';
import { useUpsellingMessage } from '../../hooks/use_upselling';
jest.mock('../user_profiles/use_get_current_user_profile');
jest.mock('../user_profiles/use_bulk_get_user_profiles');
jest.mock('../user_profiles/use_suggest_users');
jest.mock('../../hooks/use_license');
jest.mock('../../hooks/use_upselling');
const mockUserProfiles = [
{
uid: 'user-id-1',
enabled: true,
user: { username: 'user1', full_name: 'User 1', email: 'user1@test.com' },
data: {},
},
{
uid: 'user-id-2',
enabled: true,
user: { username: 'user2', full_name: 'User 2', email: 'user2@test.com' },
data: {},
},
{
uid: 'user-id-3',
enabled: true,
user: { username: 'user3', full_name: 'User 3', email: 'user3@test.com' },
data: {},
},
];
const renderFilterByAssigneesPopover = (
alertAssignees: AssigneesIdsSelection[] = [],
onUsersChange = jest.fn()
) =>
render(
<TestProviders>
<FilterByAssigneesPopover
assignedUserIds={alertAssignees}
onSelectionChange={onUsersChange}
/>
</TestProviders>
);
describe('<FilterByAssigneesPopover />', () => {
beforeEach(() => {
jest.clearAllMocks();
(useGetCurrentUserProfile as jest.Mock).mockReturnValue({
isLoading: false,
data: mockUserProfiles[0],
});
(useBulkGetUserProfiles as jest.Mock).mockReturnValue({
isLoading: false,
data: [],
});
(useSuggestUsers as jest.Mock).mockReturnValue({
isLoading: false,
data: mockUserProfiles,
});
(useLicense as jest.Mock).mockReturnValue({ isPlatinumPlus: () => true });
(useUpsellingMessage as jest.Mock).mockReturnValue('Go for Platinum!');
});
it('should render closed popover component', () => {
const { getByTestId, queryByTestId } = renderFilterByAssigneesPopover();
expect(getByTestId(TEST_IDS.FILTER_BY_ASSIGNEES_BUTTON)).toBeInTheDocument();
expect(queryByTestId('euiSelectableList')).not.toBeInTheDocument();
});
it('should render opened popover component', () => {
const { getByTestId } = renderFilterByAssigneesPopover();
getByTestId(TEST_IDS.FILTER_BY_ASSIGNEES_BUTTON).click();
expect(getByTestId('euiSelectableList')).toBeInTheDocument();
});
it('should render assignees', () => {
const { getByTestId } = renderFilterByAssigneesPopover();
getByTestId(TEST_IDS.FILTER_BY_ASSIGNEES_BUTTON).click();
const assigneesList = getByTestId('euiSelectableList');
expect(assigneesList).toHaveTextContent('User 1');
expect(assigneesList).toHaveTextContent('user1@test.com');
expect(assigneesList).toHaveTextContent('User 2');
expect(assigneesList).toHaveTextContent('user2@test.com');
expect(assigneesList).toHaveTextContent('User 3');
expect(assigneesList).toHaveTextContent('user3@test.com');
});
it('should call onUsersChange on closing the popover', () => {
const onUsersChangeMock = jest.fn();
const { getByTestId, getByText } = renderFilterByAssigneesPopover([], onUsersChangeMock);
getByTestId(TEST_IDS.FILTER_BY_ASSIGNEES_BUTTON).click();
getByText('User 1').click();
getByText('User 2').click();
getByText('User 3').click();
getByText('User 3').click();
getByText('User 2').click();
getByText('User 1').click();
expect(onUsersChangeMock).toHaveBeenCalledTimes(6);
expect(onUsersChangeMock.mock.calls).toEqual([
[['user-id-1']],
[['user-id-2', 'user-id-1']],
[['user-id-3', 'user-id-2', 'user-id-1']],
[['user-id-2', 'user-id-1']],
[['user-id-1']],
[[]],
]);
});
});

View file

@ -0,0 +1,93 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { FC } from 'react';
import React, { memo, useCallback, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFilterButton, EuiFilterGroup, EuiToolTip } from '@elastic/eui';
import { TEST_IDS } from './constants';
import { AssigneesPopover } from '../assignees/assignees_popover';
import type { AssigneesIdsSelection } from '../assignees/types';
import { useLicense } from '../../hooks/use_license';
import { useUpsellingMessage } from '../../hooks/use_upselling';
export interface FilterByAssigneesPopoverProps {
/**
* Ids of the users assigned to the alert
*/
assignedUserIds: AssigneesIdsSelection[];
/**
* Callback to handle changing of the assignees selection
*/
onSelectionChange?: (users: AssigneesIdsSelection[]) => void;
}
/**
* The popover to filter alerts by assigned users
*/
export const FilterByAssigneesPopover: FC<FilterByAssigneesPopoverProps> = memo(
({ assignedUserIds, onSelectionChange }) => {
const isPlatinumPlus = useLicense().isPlatinumPlus();
const upsellingMessage = useUpsellingMessage('alert_assignments');
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const togglePopover = useCallback(() => setIsPopoverOpen((value) => !value), []);
const [selectedAssignees, setSelectedAssignees] =
useState<AssigneesIdsSelection[]>(assignedUserIds);
const handleSelectionChange = useCallback(
(users: AssigneesIdsSelection[]) => {
setSelectedAssignees(users);
onSelectionChange?.(users);
},
[onSelectionChange]
);
return (
<EuiFilterGroup>
<AssigneesPopover
assignedUserIds={assignedUserIds}
showUnassignedOption={true}
button={
<EuiToolTip
position="bottom"
content={
upsellingMessage ??
i18n.translate('xpack.securitySolution.filtersGroup.assignees.popoverTooltip', {
defaultMessage: 'Filter by assignee',
})
}
>
<EuiFilterButton
data-test-subj={TEST_IDS.FILTER_BY_ASSIGNEES_BUTTON}
iconType="arrowDown"
badgeColor="subdued"
disabled={!isPlatinumPlus}
onClick={togglePopover}
isSelected={isPopoverOpen}
hasActiveFilters={selectedAssignees.length > 0}
numActiveFilters={selectedAssignees.length}
>
{i18n.translate('xpack.securitySolution.filtersGroup.assignees.buttonTitle', {
defaultMessage: 'Assignees',
})}
</EuiFilterButton>
</EuiToolTip>
}
isPopoverOpen={isPopoverOpen}
closePopover={togglePopover}
onSelectionChange={handleSelectionChange}
/>
</EuiFilterGroup>
);
}
);
FilterByAssigneesPopover.displayName = 'FilterByAssigneesPopover';

View file

@ -0,0 +1,189 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { TimelineItem } from '@kbn/timelines-plugin/common';
import { act, fireEvent, render } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../../mock';
import { useGetCurrentUserProfile } from '../../user_profiles/use_get_current_user_profile';
import { useBulkGetUserProfiles } from '../../user_profiles/use_bulk_get_user_profiles';
import { useSuggestUsers } from '../../user_profiles/use_suggest_users';
import { BulkAlertAssigneesPanel } from './alert_bulk_assignees';
import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils';
import { ASSIGNEES_APPLY_BUTTON_TEST_ID } from '../../assignees/test_ids';
jest.mock('../../user_profiles/use_get_current_user_profile');
jest.mock('../../user_profiles/use_bulk_get_user_profiles');
jest.mock('../../user_profiles/use_suggest_users');
const mockUserProfiles = [
{ uid: 'user-id-1', enabled: true, user: { username: 'user1' }, data: {} },
{ uid: 'user-id-2', enabled: true, user: { username: 'user2' }, data: {} },
];
const mockSuggestedUserProfiles = [
...mockUserProfiles,
{ uid: 'user-id-3', enabled: true, user: { username: 'user3' }, data: {} },
{ uid: 'user-id-4', enabled: true, user: { username: 'user4' }, data: {} },
];
const mockAlertsWithAssignees = [
{
_id: 'test-id',
data: [
{
field: ALERT_WORKFLOW_ASSIGNEE_IDS,
value: ['user-id-1', 'user-id-2'],
},
],
ecs: { _id: 'test-id' },
},
{
_id: 'test-id',
data: [
{
field: ALERT_WORKFLOW_ASSIGNEE_IDS,
value: ['user-id-1', 'user-id-2'],
},
],
ecs: { _id: 'test-id' },
},
];
(useGetCurrentUserProfile as jest.Mock).mockReturnValue({
isLoading: false,
data: mockUserProfiles[0],
});
(useBulkGetUserProfiles as jest.Mock).mockReturnValue({
isLoading: false,
data: mockUserProfiles,
});
(useSuggestUsers as jest.Mock).mockReturnValue({
isLoading: false,
data: mockSuggestedUserProfiles,
});
const renderAssigneesMenu = (
items: TimelineItem[],
closePopover: () => void = jest.fn(),
onSubmit: () => Promise<void> = jest.fn(),
setIsLoading: () => void = jest.fn()
) => {
return render(
<TestProviders>
<BulkAlertAssigneesPanel
alertItems={items}
setIsLoading={setIsLoading}
closePopoverMenu={closePopover}
onSubmit={onSubmit}
/>
</TestProviders>
);
};
describe('BulkAlertAssigneesPanel', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('it renders', () => {
const wrapper = renderAssigneesMenu(mockAlertsWithAssignees);
expect(wrapper.getByTestId(ASSIGNEES_APPLY_BUTTON_TEST_ID)).toBeInTheDocument();
expect(useSuggestUsers).toHaveBeenCalled();
});
test('it calls expected functions on submit when nothing has changed', () => {
const mockedClosePopover = jest.fn();
const mockedOnSubmit = jest.fn();
const mockedSetIsLoading = jest.fn();
const wrapper = renderAssigneesMenu(
mockAlertsWithAssignees,
mockedClosePopover,
mockedOnSubmit,
mockedSetIsLoading
);
act(() => {
fireEvent.click(wrapper.getByTestId(ASSIGNEES_APPLY_BUTTON_TEST_ID));
});
expect(mockedClosePopover).toHaveBeenCalled();
expect(mockedOnSubmit).not.toHaveBeenCalled();
expect(mockedSetIsLoading).not.toHaveBeenCalled();
});
test('it updates state correctly', () => {
const wrapper = renderAssigneesMenu(mockAlertsWithAssignees);
const deselectUser = (userName: string, index: number) => {
expect(wrapper.getAllByRole('option')[index]).toHaveAttribute('title', userName);
expect(wrapper.getAllByRole('option')[index]).toBeChecked();
act(() => {
fireEvent.click(wrapper.getByText(userName));
});
expect(wrapper.getAllByRole('option')[index]).toHaveAttribute('title', userName);
expect(wrapper.getAllByRole('option')[index]).not.toBeChecked();
};
const selectUser = (userName: string, index = 0) => {
expect(wrapper.getAllByRole('option')[index]).toHaveAttribute('title', userName);
expect(wrapper.getAllByRole('option')[index]).not.toBeChecked();
act(() => {
fireEvent.click(wrapper.getByText(userName));
});
expect(wrapper.getAllByRole('option')[index]).toHaveAttribute('title', userName);
expect(wrapper.getAllByRole('option')[index]).toBeChecked();
};
deselectUser('user1', 0);
deselectUser('user2', 1);
selectUser('user3', 2);
selectUser('user4', 3);
});
test('it calls expected functions on submit when alerts have changed', () => {
const mockedClosePopover = jest.fn();
const mockedOnSubmit = jest.fn();
const mockedSetIsLoading = jest.fn();
const wrapper = renderAssigneesMenu(
mockAlertsWithAssignees,
mockedClosePopover,
mockedOnSubmit,
mockedSetIsLoading
);
act(() => {
fireEvent.click(wrapper.getByText('user1'));
});
act(() => {
fireEvent.click(wrapper.getByText('user2'));
});
act(() => {
fireEvent.click(wrapper.getByText('user3'));
});
act(() => {
fireEvent.click(wrapper.getByText('user4'));
});
act(() => {
fireEvent.click(wrapper.getByTestId(ASSIGNEES_APPLY_BUTTON_TEST_ID));
});
expect(mockedClosePopover).toHaveBeenCalled();
expect(mockedOnSubmit).toHaveBeenCalled();
expect(mockedOnSubmit).toHaveBeenCalledWith(
{
add: ['user-id-4', 'user-id-3'],
remove: ['user-id-1', 'user-id-2'],
},
['test-id', 'test-id'],
expect.anything(), // An anonymous callback defined in the onSubmit function
mockedSetIsLoading
);
});
});

View file

@ -0,0 +1,82 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { intersection } from 'lodash';
import React, { memo, useCallback, useMemo } from 'react';
import type { TimelineItem } from '@kbn/timelines-plugin/common';
import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils';
import type { SetAlertAssigneesFunc } from './use_set_alert_assignees';
import { AssigneesApplyPanel } from '../../assignees/assignees_apply_panel';
import type { AssigneesIdsSelection } from '../../assignees/types';
import { removeNoAssigneesSelection } from '../../assignees/utils';
interface BulkAlertAssigneesPanelComponentProps {
alertItems: TimelineItem[];
setIsLoading: (isLoading: boolean) => void;
refresh?: () => void;
clearSelection?: () => void;
closePopoverMenu: () => void;
onSubmit: SetAlertAssigneesFunc;
}
const BulkAlertAssigneesPanelComponent: React.FC<BulkAlertAssigneesPanelComponentProps> = ({
alertItems,
refresh,
setIsLoading,
clearSelection,
closePopoverMenu,
onSubmit,
}) => {
const assignedUserIds = useMemo(
() =>
intersection(
...alertItems.map(
(item) =>
item.data.find((data) => data.field === ALERT_WORKFLOW_ASSIGNEE_IDS)?.value ?? []
)
),
[alertItems]
);
const onAssigneesApply = useCallback(
async (assigneesIds: AssigneesIdsSelection[]) => {
const updatedIds = removeNoAssigneesSelection(assigneesIds);
const assigneesToAddArray = updatedIds.filter((uid) => uid && !assignedUserIds.includes(uid));
const assigneesToRemoveArray = assignedUserIds.filter(
(uid) => uid && !updatedIds.includes(uid)
);
if (assigneesToAddArray.length === 0 && assigneesToRemoveArray.length === 0) {
closePopoverMenu();
return;
}
const ids = alertItems.map((item) => item._id);
const assignees = {
add: assigneesToAddArray,
remove: assigneesToRemoveArray,
};
const onSuccess = () => {
if (refresh) refresh();
if (clearSelection) clearSelection();
};
if (onSubmit != null) {
closePopoverMenu();
await onSubmit(assignees, ids, onSuccess, setIsLoading);
}
},
[alertItems, assignedUserIds, clearSelection, closePopoverMenu, onSubmit, refresh, setIsLoading]
);
return (
<div data-test-subj="alert-assignees-selectable-menu">
<AssigneesApplyPanel assignedUserIds={assignedUserIds} onAssigneesApply={onAssigneesApply} />
</div>
);
};
export const BulkAlertAssigneesPanel = memo(BulkAlertAssigneesPanelComponent);

View file

@ -211,3 +211,31 @@ export const ALERT_TAGS_CONTEXT_MENU_ITEM_TOOLTIP_INFO = i18n.translate(
defaultMessage: 'Change alert tag options in Kibana Advanced Settings.',
}
);
export const UPDATE_ALERT_ASSIGNEES_SUCCESS_TOAST = (totalAlerts: number) =>
i18n.translate('xpack.securitySolution.bulkActions.updateAlertAssigneesSuccessToastMessage', {
values: { totalAlerts },
defaultMessage:
'Successfully updated assignees for {totalAlerts} {totalAlerts, plural, =1 {alert} other {alerts}}.',
});
export const UPDATE_ALERT_ASSIGNEES_FAILURE = i18n.translate(
'xpack.securitySolution.bulkActions.updateAlertAssigneesFailedToastMessage',
{
defaultMessage: 'Failed to update alert assignees.',
}
);
export const ALERT_ASSIGNEES_CONTEXT_MENU_ITEM_TITLE = i18n.translate(
'xpack.securitySolution.bulkActions.alertAssigneesContextMenuItemTitle',
{
defaultMessage: 'Assign alert',
}
);
export const REMOVE_ALERT_ASSIGNEES_CONTEXT_MENU_TITLE = i18n.translate(
'xpack.securitySolution.bulkActions.removeAlertAssignessContextMenuTitle',
{
defaultMessage: 'Unassign alert',
}
);

View file

@ -0,0 +1,192 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils';
import { TestProviders } from '@kbn/timelines-plugin/public/mock';
import type { BulkActionsConfig } from '@kbn/triggers-actions-ui-plugin/public/types';
import type { TimelineItem } from '@kbn/triggers-actions-ui-plugin/public/application/sections/alerts_table/bulk_actions/components/toolbar';
import { act, fireEvent, render } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import type {
UseBulkAlertAssigneesItemsProps,
UseBulkAlertAssigneesPanel,
} from './use_bulk_alert_assignees_items';
import { useBulkAlertAssigneesItems } from './use_bulk_alert_assignees_items';
import { useSetAlertAssignees } from './use_set_alert_assignees';
import { useGetCurrentUserProfile } from '../../user_profiles/use_get_current_user_profile';
import { useBulkGetUserProfiles } from '../../user_profiles/use_bulk_get_user_profiles';
import { useSuggestUsers } from '../../user_profiles/use_suggest_users';
import { ASSIGNEES_APPLY_BUTTON_TEST_ID } from '../../assignees/test_ids';
import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges';
import { useLicense } from '../../../hooks/use_license';
jest.mock('./use_set_alert_assignees');
jest.mock('../../user_profiles/use_get_current_user_profile');
jest.mock('../../user_profiles/use_bulk_get_user_profiles');
jest.mock('../../user_profiles/use_suggest_users');
jest.mock('../../../../detections/containers/detection_engine/alerts/use_alerts_privileges');
jest.mock('../../../hooks/use_license');
const mockUserProfiles = [
{ uid: 'user-id-1', enabled: true, user: { username: 'fakeUser1' }, data: {} },
{ uid: 'user-id-2', enabled: true, user: { username: 'fakeUser2' }, data: {} },
];
const defaultProps: UseBulkAlertAssigneesItemsProps = {
onAssigneesUpdate: () => {},
};
const mockAssigneeItems = [
{
_id: 'test-id',
data: [{ field: ALERT_WORKFLOW_ASSIGNEE_IDS, value: ['user-id-1', 'user-id-2'] }],
ecs: { _id: 'test-id', _index: 'test-index' },
},
];
const renderPanel = (panel: UseBulkAlertAssigneesPanel) => {
const content = panel.renderContent({
closePopoverMenu: jest.fn(),
setIsBulkActionsLoading: jest.fn(),
alertItems: mockAssigneeItems,
});
return render(content);
};
describe('useBulkAlertAssigneesItems', () => {
beforeEach(() => {
(useSetAlertAssignees as jest.Mock).mockReturnValue(jest.fn());
(useGetCurrentUserProfile as jest.Mock).mockReturnValue({
isLoading: false,
data: mockUserProfiles[0],
});
(useBulkGetUserProfiles as jest.Mock).mockReturnValue({
isLoading: false,
data: mockUserProfiles,
});
(useSuggestUsers as jest.Mock).mockReturnValue({
isLoading: false,
data: mockUserProfiles,
});
(useAlertsPrivileges as jest.Mock).mockReturnValue({ hasIndexWrite: true });
(useLicense as jest.Mock).mockReturnValue({ isPlatinumPlus: () => true });
});
afterEach(() => {
jest.clearAllMocks();
});
it('should return two alert assignees action items and one panel', () => {
const { result } = renderHook(() => useBulkAlertAssigneesItems(defaultProps), {
wrapper: TestProviders,
});
expect(result.current.alertAssigneesItems.length).toEqual(2);
expect(result.current.alertAssigneesPanels.length).toEqual(1);
expect(result.current.alertAssigneesItems[0]['data-test-subj']).toEqual(
'alert-assignees-context-menu-item'
);
expect(result.current.alertAssigneesItems[1]['data-test-subj']).toEqual(
'remove-alert-assignees-menu-item'
);
expect(result.current.alertAssigneesPanels[0]['data-test-subj']).toEqual(
'alert-assignees-context-menu-panel'
);
});
it('should still render alert assignees panel when useSetAlertAssignees is null', () => {
(useSetAlertAssignees as jest.Mock).mockReturnValue(null);
const { result } = renderHook(() => useBulkAlertAssigneesItems(defaultProps), {
wrapper: TestProviders,
});
expect(result.current.alertAssigneesPanels[0]['data-test-subj']).toEqual(
'alert-assignees-context-menu-panel'
);
const wrapper = renderPanel(result.current.alertAssigneesPanels[0]);
expect(wrapper.getByTestId('alert-assignees-selectable-menu')).toBeInTheDocument();
});
it('should call setAlertAssignees on submit', () => {
const mockSetAlertAssignees = jest.fn();
(useSetAlertAssignees as jest.Mock).mockReturnValue(mockSetAlertAssignees);
const { result } = renderHook(() => useBulkAlertAssigneesItems(defaultProps), {
wrapper: TestProviders,
});
const wrapper = renderPanel(result.current.alertAssigneesPanels[0]);
expect(wrapper.getByTestId('alert-assignees-selectable-menu')).toBeInTheDocument();
act(() => {
fireEvent.click(wrapper.getByText('fakeUser2')); // Won't fire unless component assignees selection has been changed
});
act(() => {
fireEvent.click(wrapper.getByTestId(ASSIGNEES_APPLY_BUTTON_TEST_ID));
});
expect(mockSetAlertAssignees).toHaveBeenCalled();
});
it('should call setAlertAssignees with the correct parameters on `Unassign alert` button click', () => {
const mockSetAlertAssignees = jest.fn();
(useSetAlertAssignees as jest.Mock).mockReturnValue(mockSetAlertAssignees);
const { result } = renderHook(() => useBulkAlertAssigneesItems(defaultProps), {
wrapper: TestProviders,
});
const items: TimelineItem[] = [
{
_id: 'alert1',
data: [{ field: ALERT_WORKFLOW_ASSIGNEE_IDS, value: ['user1', 'user2'] }],
ecs: { _id: 'alert1', _index: 'index1' },
},
{
_id: 'alert2',
data: [{ field: ALERT_WORKFLOW_ASSIGNEE_IDS, value: ['user1', 'user3'] }],
ecs: { _id: 'alert2', _index: 'index1' },
},
{
_id: 'alert3',
data: [],
ecs: { _id: 'alert3', _index: 'index1' },
},
];
const setAlertLoadingMock = jest.fn();
(
result.current.alertAssigneesItems[1] as unknown as { onClick: BulkActionsConfig['onClick'] }
).onClick?.(items, true, setAlertLoadingMock, jest.fn(), jest.fn());
expect(mockSetAlertAssignees).toHaveBeenCalled();
expect(mockSetAlertAssignees).toHaveBeenCalledWith(
{ add: [], remove: ['user1', 'user2', 'user3'] },
['alert1', 'alert2', 'alert3'],
expect.any(Function),
setAlertLoadingMock
);
});
it('should return 0 items for the VIEWER role', () => {
(useAlertsPrivileges as jest.Mock).mockReturnValue({ hasIndexWrite: false });
const { result } = renderHook(() => useBulkAlertAssigneesItems(defaultProps), {
wrapper: TestProviders,
});
expect(result.current.alertAssigneesItems.length).toEqual(0);
expect(result.current.alertAssigneesPanels.length).toEqual(0);
});
it('should return 0 items for the Basic license', () => {
(useLicense as jest.Mock).mockReturnValue({ isPlatinumPlus: () => false });
const { result } = renderHook(() => useBulkAlertAssigneesItems(defaultProps), {
wrapper: TestProviders,
});
expect(result.current.alertAssigneesItems.length).toEqual(0);
expect(result.current.alertAssigneesPanels.length).toEqual(0);
});
});

View file

@ -0,0 +1,158 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { union } from 'lodash';
import React, { useCallback, useMemo } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils';
import type {
BulkActionsConfig,
RenderContentPanelProps,
} from '@kbn/triggers-actions-ui-plugin/public/types';
import { useLicense } from '../../../hooks/use_license';
import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges';
import { ASSIGNEES_PANEL_WIDTH } from '../../assignees/constants';
import { BulkAlertAssigneesPanel } from './alert_bulk_assignees';
import * as i18n from './translations';
import { useSetAlertAssignees } from './use_set_alert_assignees';
export interface UseBulkAlertAssigneesItemsProps {
onAssigneesUpdate?: () => void;
}
export interface UseBulkAlertAssigneesPanel {
id: number;
title: JSX.Element;
'data-test-subj': string;
renderContent: (props: RenderContentPanelProps) => JSX.Element;
width?: number;
}
export const useBulkAlertAssigneesItems = ({
onAssigneesUpdate,
}: UseBulkAlertAssigneesItemsProps) => {
const isPlatinumPlus = useLicense().isPlatinumPlus();
const { hasIndexWrite } = useAlertsPrivileges();
const setAlertAssignees = useSetAlertAssignees();
const handleOnAlertAssigneesSubmit = useCallback(
async (assignees, ids, onSuccess, setIsLoading) => {
if (setAlertAssignees) {
await setAlertAssignees(assignees, ids, onSuccess, setIsLoading);
}
},
[setAlertAssignees]
);
const onSuccess = useCallback(() => {
onAssigneesUpdate?.();
}, [onAssigneesUpdate]);
const onRemoveAllAssignees = useCallback<Required<BulkActionsConfig>['onClick']>(
async (items, _, setAlertLoading) => {
const ids: string[] = items.map((item) => item._id);
const assignedUserIds = union(
...items.map(
(item) =>
item.data.find((data) => data.field === ALERT_WORKFLOW_ASSIGNEE_IDS)?.value ?? []
)
);
if (!assignedUserIds.length) {
return;
}
const assignees = {
add: [],
remove: assignedUserIds,
};
if (setAlertAssignees) {
await setAlertAssignees(assignees, ids, onSuccess, setAlertLoading);
}
},
[onSuccess, setAlertAssignees]
);
const alertAssigneesItems = useMemo(
() =>
hasIndexWrite && isPlatinumPlus
? [
{
key: 'manage-alert-assignees',
'data-test-subj': 'alert-assignees-context-menu-item',
name: i18n.ALERT_ASSIGNEES_CONTEXT_MENU_ITEM_TITLE,
panel: 2,
label: i18n.ALERT_ASSIGNEES_CONTEXT_MENU_ITEM_TITLE,
disableOnQuery: true,
},
{
key: 'remove-all-alert-assignees',
'data-test-subj': 'remove-alert-assignees-menu-item',
name: i18n.REMOVE_ALERT_ASSIGNEES_CONTEXT_MENU_TITLE,
label: i18n.REMOVE_ALERT_ASSIGNEES_CONTEXT_MENU_TITLE,
disableOnQuery: true,
onClick: onRemoveAllAssignees,
},
]
: [],
[hasIndexWrite, isPlatinumPlus, onRemoveAllAssignees]
);
const TitleContent = useMemo(
() => (
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
<EuiFlexItem grow={false}>{i18n.ALERT_ASSIGNEES_CONTEXT_MENU_ITEM_TITLE}</EuiFlexItem>
</EuiFlexGroup>
),
[]
);
const renderContent = useCallback(
({
alertItems,
refresh,
setIsBulkActionsLoading,
clearSelection,
closePopoverMenu,
}: RenderContentPanelProps) => (
<BulkAlertAssigneesPanel
alertItems={alertItems}
refresh={() => {
onSuccess();
refresh?.();
}}
setIsLoading={setIsBulkActionsLoading}
clearSelection={clearSelection}
closePopoverMenu={closePopoverMenu}
onSubmit={handleOnAlertAssigneesSubmit}
/>
),
[handleOnAlertAssigneesSubmit, onSuccess]
);
const alertAssigneesPanels: UseBulkAlertAssigneesPanel[] = useMemo(
() =>
hasIndexWrite && isPlatinumPlus
? [
{
id: 2,
title: TitleContent,
'data-test-subj': 'alert-assignees-context-menu-panel',
renderContent,
width: ASSIGNEES_PANEL_WIDTH,
},
]
: [],
[TitleContent, hasIndexWrite, isPlatinumPlus, renderContent]
);
return {
alertAssigneesItems,
alertAssigneesPanels,
};
};

View file

@ -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<void>;
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<CoreStart>().services;
const { addSuccess, addError } = useAppToasts();
const setAlertAssigneesRef = useRef<SetAlertAssigneesFunc | null>(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;
};

View file

@ -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<UserProfileWithAvatar[]> => Promise.resolve(mockUserProfiles);

View file

@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { coreMock } from '@kbn/core/public/mocks';
import { mockUserProfiles } from './mock';
import { suggestUsers } from './api';
import { KibanaServices } from '../../lib/kibana';
import { DETECTION_ENGINE_ALERT_SUGGEST_USERS_URL } from '../../../../common/constants';
const mockKibanaServices = KibanaServices.get as jest.Mock;
jest.mock('../../lib/kibana');
const coreStartMock = coreMock.createStart({ basePath: '/mock' });
mockKibanaServices.mockReturnValue(coreStartMock);
const fetchMock = coreStartMock.http.fetch;
describe('Detections Alerts API', () => {
describe('suggestUsers', () => {
beforeEach(() => {
fetchMock.mockClear();
fetchMock.mockResolvedValue(mockUserProfiles);
});
test('check parameter url', async () => {
await suggestUsers({ searchTerm: 'name1' });
expect(fetchMock).toHaveBeenCalledWith(
DETECTION_ENGINE_ALERT_SUGGEST_USERS_URL,
expect.objectContaining({
method: 'GET',
version: '2023-10-31',
query: { searchTerm: 'name1' },
})
);
});
test('happy path', async () => {
const alertsResp = await suggestUsers({ searchTerm: '' });
expect(alertsResp).toEqual(mockUserProfiles);
});
});
});

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { UserProfileWithAvatar } from '@kbn/user-profile-components';
import type { SuggestUsersProps } from './types';
import { DETECTION_ENGINE_ALERT_SUGGEST_USERS_URL } from '../../../../common/constants';
import { KibanaServices } from '../../lib/kibana';
/**
* Fetches suggested user profiles
*/
export const suggestUsers = async ({
searchTerm,
}: SuggestUsersProps): Promise<UserProfileWithAvatar[]> => {
return KibanaServices.get().http.fetch<UserProfileWithAvatar[]>(
DETECTION_ENGINE_ALERT_SUGGEST_USERS_URL,
{
method: 'GET',
version: '2023-10-31',
query: { searchTerm },
}
);
};

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const mockCurrentUserProfile = {
uid: 'current-user',
enabled: true,
user: { username: 'current.user' },
data: {},
};
export const mockUserProfiles = [
{ uid: 'user-id-1', enabled: true, user: { username: 'user1' }, data: {} },
{ uid: 'user-id-2', enabled: true, user: { username: 'user2' }, data: {} },
];

View file

@ -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`;

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const CURRENT_USER_PROFILE_FAILURE = i18n.translate(
'xpack.securitySolution.userProfiles.fetchCurrentUserProfile.failure',
{ defaultMessage: 'Failed to find current user' }
);
export const USER_PROFILES_FAILURE = i18n.translate(
'xpack.securitySolution.userProfiles.fetchUserProfiles.failure',
{
defaultMessage: 'Failed to find users',
}
);
/**
* Used whenever we need to display a user name and for some reason it is not available
*/
export const UNKNOWN_USER_PROFILE_NAME = i18n.translate(
'xpack.securitySolution.userProfiles.unknownUser.displayName',
{ defaultMessage: 'Unknown' }
);

View file

@ -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;
}

View file

@ -0,0 +1,54 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { renderHook } from '@testing-library/react-hooks';
import { securityMock } from '@kbn/security-plugin/public/mocks';
import { mockUserProfiles } from './mock';
import { useBulkGetUserProfiles } from './use_bulk_get_user_profiles';
import { useKibana } from '../../lib/kibana';
import { useAppToasts } from '../../hooks/use_app_toasts';
import { useAppToastsMock } from '../../hooks/use_app_toasts.mock';
import { createStartServicesMock } from '../../lib/kibana/kibana_react.mock';
import { TestProviders } from '../../mock';
jest.mock('../../lib/kibana');
jest.mock('../../hooks/use_app_toasts');
describe('useBulkGetUserProfiles hook', () => {
let appToastsMock: jest.Mocked<ReturnType<typeof useAppToastsMock.create>>;
beforeEach(() => {
jest.clearAllMocks();
appToastsMock = useAppToastsMock.create();
(useAppToasts as jest.Mock).mockReturnValue(appToastsMock);
const security = securityMock.createStart();
security.userProfiles.bulkGet.mockReturnValue(Promise.resolve(mockUserProfiles));
(useKibana as jest.Mock).mockReturnValue({
services: {
...createStartServicesMock(),
security,
},
});
});
it('returns an array of userProfiles', async () => {
const userProfiles = useKibana().services.security.userProfiles;
const spyOnUserProfiles = jest.spyOn(userProfiles, 'bulkGet');
const assigneesIds = new Set(['user1']);
const { result, waitForNextUpdate } = renderHook(
() => useBulkGetUserProfiles({ uids: assigneesIds }),
{
wrapper: TestProviders,
}
);
await waitForNextUpdate();
expect(spyOnUserProfiles).toHaveBeenCalledTimes(1);
expect(result.current.isLoading).toEqual(false);
expect(result.current.data).toEqual(mockUserProfiles);
});
});

View file

@ -0,0 +1,48 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { SecurityPluginStart } from '@kbn/security-plugin/public';
import type { UserProfile } from '@kbn/security-plugin/common';
import type { UserProfileWithAvatar } from '@kbn/user-profile-components';
import { useQuery } from '@tanstack/react-query';
import { useKibana } from '../../lib/kibana';
import { useAppToasts } from '../../hooks/use_app_toasts';
import { USER_PROFILES_FAILURE } from './translations';
export interface BulkGetUserProfilesArgs {
security: SecurityPluginStart;
uids: Set<string>;
}
export const bulkGetUserProfiles = async ({
security,
uids,
}: BulkGetUserProfilesArgs): Promise<UserProfile[]> => {
if (uids.size === 0) {
return [];
}
return security.userProfiles.bulkGet({ uids, dataPath: 'avatar' });
};
export const useBulkGetUserProfiles = ({ uids }: { uids: Set<string> }) => {
const { security } = useKibana().services;
const { addError } = useAppToasts();
return useQuery<UserProfileWithAvatar[]>(
['useBulkGetUserProfiles', ...uids],
async () => {
return bulkGetUserProfiles({ security, uids });
},
{
retry: false,
staleTime: Infinity,
onError: (e) => {
addError(e, { title: USER_PROFILES_FAILURE });
},
}
);
};

View file

@ -0,0 +1,50 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { renderHook } from '@testing-library/react-hooks';
import { securityMock } from '@kbn/security-plugin/public/mocks';
import { mockCurrentUserProfile } from './mock';
import { useGetCurrentUserProfile } from './use_get_current_user_profile';
import { useKibana } from '../../lib/kibana';
import { useAppToasts } from '../../hooks/use_app_toasts';
import { useAppToastsMock } from '../../hooks/use_app_toasts.mock';
import { createStartServicesMock } from '../../lib/kibana/kibana_react.mock';
import { TestProviders } from '../../mock';
jest.mock('../../lib/kibana');
jest.mock('../../hooks/use_app_toasts');
describe('useGetCurrentUserProfile hook', () => {
let appToastsMock: jest.Mocked<ReturnType<typeof useAppToastsMock.create>>;
beforeEach(() => {
jest.clearAllMocks();
appToastsMock = useAppToastsMock.create();
(useAppToasts as jest.Mock).mockReturnValue(appToastsMock);
const security = securityMock.createStart();
security.userProfiles.getCurrent.mockReturnValue(Promise.resolve(mockCurrentUserProfile));
(useKibana as jest.Mock).mockReturnValue({
services: {
...createStartServicesMock(),
security,
},
});
});
it('returns current user', async () => {
const userProfiles = useKibana().services.security.userProfiles;
const spyOnUserProfiles = jest.spyOn(userProfiles, 'getCurrent');
const { result, waitForNextUpdate } = renderHook(() => useGetCurrentUserProfile(), {
wrapper: TestProviders,
});
await waitForNextUpdate();
expect(spyOnUserProfiles).toHaveBeenCalledTimes(1);
expect(result.current.isLoading).toEqual(false);
expect(result.current.data).toEqual(mockCurrentUserProfile);
});
});

View file

@ -0,0 +1,47 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useQuery } from '@tanstack/react-query';
import type { SecurityPluginStart } from '@kbn/security-plugin/public';
import type { UserProfileWithAvatar } from '@kbn/user-profile-components';
import { CURRENT_USER_PROFILE_FAILURE } from './translations';
import { useKibana } from '../../lib/kibana';
import { useAppToasts } from '../../hooks/use_app_toasts';
export const getCurrentUserProfile = async ({
security,
}: {
security: SecurityPluginStart;
}): Promise<UserProfileWithAvatar> => {
return security.userProfiles.getCurrent({ dataPath: 'avatar' });
};
/**
* Fetches current user profile using `userProfiles` service via `security.userProfiles.getCurrent()`
*
* NOTE: There is a similar hook `useCurrentUser` which fetches current authenticated user via `security.authc.getCurrentUser()`
*/
export const useGetCurrentUserProfile = () => {
const { security } = useKibana().services;
const { addError } = useAppToasts();
return useQuery<UserProfileWithAvatar>(
['useGetCurrentUserProfile'],
async () => {
return getCurrentUserProfile({ security });
},
{
retry: false,
staleTime: Infinity,
onError: (e) => {
addError(e, { title: CURRENT_USER_PROFILE_FAILURE });
},
}
);
};

View file

@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { renderHook } from '@testing-library/react-hooks';
import { useSuggestUsers } from './use_suggest_users';
import * as api from './api';
import { mockUserProfiles } from './mock';
import { useAppToasts } from '../../hooks/use_app_toasts';
import { useAppToastsMock } from '../../hooks/use_app_toasts.mock';
import { TestProviders } from '../../mock';
jest.mock('./api');
jest.mock('../../hooks/use_app_toasts');
describe('useSuggestUsers hook', () => {
let appToastsMock: jest.Mocked<ReturnType<typeof useAppToastsMock.create>>;
beforeEach(() => {
jest.clearAllMocks();
appToastsMock = useAppToastsMock.create();
(useAppToasts as jest.Mock).mockReturnValue(appToastsMock);
});
it('returns an array of userProfiles', async () => {
const spyOnUserProfiles = jest.spyOn(api, 'suggestUsers');
const { result, waitForNextUpdate } = renderHook(() => useSuggestUsers({ searchTerm: '' }), {
wrapper: TestProviders,
});
await waitForNextUpdate();
expect(spyOnUserProfiles).toHaveBeenCalledTimes(1);
expect(result.current.isLoading).toEqual(false);
expect(result.current.data).toEqual(mockUserProfiles);
});
});

View file

@ -0,0 +1,44 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useQuery } from '@tanstack/react-query';
import type { UserProfileWithAvatar } from '@kbn/user-profile-components';
import { suggestUsers } from './api';
import { USER_PROFILES_FAILURE } from './translations';
import { useAppToasts } from '../../hooks/use_app_toasts';
export interface SuggestUserProfilesArgs {
searchTerm: string;
}
export const bulkGetUserProfiles = async ({
searchTerm,
}: {
searchTerm: string;
}): Promise<UserProfileWithAvatar[]> => {
return suggestUsers({ searchTerm });
};
export const useSuggestUsers = ({ searchTerm }: { searchTerm: string }) => {
const { addError } = useAppToasts();
return useQuery<UserProfileWithAvatar[]>(
['useSuggestUsers', searchTerm],
async () => {
return bulkGetUserProfiles({ searchTerm });
},
{
retry: false,
staleTime: Infinity,
onError: (e) => {
addError(e, { title: USER_PROFILES_FAILURE });
},
}
);
};

View file

@ -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(
<TestProviders>
<UsersAvatarsPanel userProfiles={userProfiles} maxVisibleAvatars={maxVisibleAvatars} />
</TestProviders>
);
describe('<UsersAvatarsPanel />', () => {
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();
});
});

View file

@ -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<UsersAvatarsPanelProps> = memo(
({ userProfiles, maxVisibleAvatars }) => {
if (maxVisibleAvatars && userProfiles.length > maxVisibleAvatars) {
return (
<EuiToolTip
position="top"
content={userProfiles.map((user) => (
<div>{user ? user.user.email ?? user.user.username : UNKNOWN_USER_PROFILE_NAME}</div>
))}
repositionOnScroll={true}
>
<EuiNotificationBadge data-test-subj={USERS_AVATARS_COUNT_BADGE_TEST_ID} color="subdued">
{userProfiles.length}
</EuiNotificationBadge>
</EuiToolTip>
);
}
return (
<EuiFlexGroup
data-test-subj={USERS_AVATARS_PANEL_TEST_ID}
alignItems="center"
direction="row"
gutterSize="xs"
>
{userProfiles.map((user, index) => (
<EuiFlexItem key={index} grow={false}>
<UserAvatar
data-test-subj={USER_AVATAR_ITEM_TEST_ID(user?.user.username ?? `Unknown-${index}`)}
user={user?.user}
avatar={user?.data.avatar}
size={'s'}
/>
</EuiFlexItem>
))}
</EuiFlexGroup>
);
}
);
UsersAvatarsPanel.displayName = 'UsersAvatarsPanel';

View file

@ -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<estypes.UpdateByQueryResponse> => {
return KibanaServices.get().http.fetch<estypes.UpdateByQueryResponse>(
DETECTION_ENGINE_ALERT_ASSIGNEES_URL,
{
method: 'POST',
version: '2023-10-31',
body: JSON.stringify({ assignees, ids }),
signal,
}
);
};

View file

@ -23,6 +23,7 @@ import {
import { getDataTablesInStorageByIds } from '../../../timelines/containers/local_storage';
import { getColumns } from '../../../detections/configurations/security_solution_detections';
import { getRenderCellValueHook } from '../../../detections/configurations/security_solution_detections/render_cell_value';
import { useFetchPageContext } from '../../../detections/configurations/security_solution_detections/fetch_page_context';
import { SourcererScopeName } from '../../store/sourcerer/model';
const registerAlertsTableConfiguration = (
@ -64,6 +65,7 @@ const registerAlertsTableConfiguration = (
sort,
useFieldBrowserOptions: getUseTriggersActionsFieldBrowserOptions(SourcererScopeName.detections),
showInspectButton: true,
useFetchPageContext,
});
// register Alert Table on RuleDetails Page
@ -79,6 +81,7 @@ const registerAlertsTableConfiguration = (
sort,
useFieldBrowserOptions: getUseTriggersActionsFieldBrowserOptions(SourcererScopeName.detections),
showInspectButton: true,
useFetchPageContext,
});
registerIfNotAlready(registry, {
@ -91,6 +94,7 @@ const registerAlertsTableConfiguration = (
useCellActions: getUseCellActionsHook(TableId.alertsOnCasePage),
sort,
showInspectButton: true,
useFetchPageContext,
});
registerIfNotAlready(registry, {
@ -104,6 +108,7 @@ const registerAlertsTableConfiguration = (
usePersistentControls: getPersistentControlsHook(TableId.alertsRiskInputs),
sort,
showInspectButton: true,
useFetchPageContext,
});
};

View file

@ -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;

View file

@ -9,11 +9,13 @@ import {
ALERT_BUILDING_BLOCK_TYPE,
ALERT_WORKFLOW_STATUS,
ALERT_RULE_RULE_ID,
ALERT_WORKFLOW_ASSIGNEE_IDS,
} from '@kbn/rule-data-utils';
import type { Filter } from '@kbn/es-query';
import { tableDefaults } from '@kbn/securitysolution-data-table';
import type { SubsetDataTableModel } from '@kbn/securitysolution-data-table';
import type { AssigneesIdsSelection } from '../../../common/components/assignees/types';
import type { Status } from '../../../../common/api/detection_engine';
import {
getColumns,
@ -152,6 +154,36 @@ export const buildThreatMatchFilter = (showOnlyThreatIndicatorAlerts: boolean):
]
: [];
export const buildAlertAssigneesFilter = (assigneesIds: AssigneesIdsSelection[]): Filter[] => {
if (!assigneesIds.length) {
return [];
}
const combinedQuery = {
bool: {
should: assigneesIds.map((id) =>
id
? {
term: {
[ALERT_WORKFLOW_ASSIGNEE_IDS]: id,
},
}
: { bool: { must_not: { exists: { field: ALERT_WORKFLOW_ASSIGNEE_IDS } } } }
),
},
};
return [
{
meta: {
alias: null,
negate: false,
disabled: false,
},
query: combinedQuery,
},
];
};
export const getAlertsDefaultModel = (license?: LicenseService): SubsetDataTableModel => ({
...tableDefaults,
columns: getColumns(license),
@ -177,6 +209,7 @@ export const requiredFieldsForActions = [
'@timestamp',
'kibana.alert.workflow_status',
'kibana.alert.workflow_tags',
'kibana.alert.workflow_assignee_ids',
'kibana.alert.group.id',
'kibana.alert.original_time',
'kibana.alert.building_block_type',

View file

@ -28,6 +28,10 @@ jest.mock('../../../../common/hooks/use_experimental_features', () => ({
useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true),
}));
jest.mock('../../../../common/hooks/use_license', () => ({
useLicense: jest.fn().mockReturnValue({ isPlatinumPlus: () => true }),
}));
const ecsRowData: Ecs = {
_id: '1',
agent: { type: ['blah'] },
@ -105,6 +109,7 @@ const markAsAcknowledgedButton = '[data-test-subj="acknowledged-alert-status"]';
const markAsClosedButton = '[data-test-subj="close-alert-status"]';
const addEndpointEventFilterButton = '[data-test-subj="add-event-filter-menu-item"]';
const applyAlertTagsButton = '[data-test-subj="alert-tags-context-menu-item"]';
const applyAlertAssigneesButton = '[data-test-subj="alert-assignees-context-menu-item"]';
describe('Alert table context menu', () => {
describe('Case actions', () => {
@ -304,4 +309,16 @@ describe('Alert table context menu', () => {
expect(wrapper.find(applyAlertTagsButton).first().exists()).toEqual(true);
});
});
describe('Assign alert action', () => {
test('it renders the assign alert action button', () => {
const wrapper = mount(<AlertContextMenu {...props} scopeId={TimelineId.active} />, {
wrappingComponent: TestProviders,
});
wrapper.find(actionMenuButton).simulate('click');
expect(wrapper.find(applyAlertAssigneesButton).first().exists()).toEqual(true);
});
});
});

View file

@ -50,6 +50,7 @@ import { isAlertFromEndpointAlert } from '../../../../common/utils/endpoint_aler
import type { Rule } from '../../../../detection_engine/rule_management/logic/types';
import type { AlertTableContextMenuItem } from '../types';
import { useAlertTagsActions } from './use_alert_tags_actions';
import { useAlertAssigneesActions } from './use_alert_assignees_actions';
interface AlertContextMenuProps {
ariaLabel?: string;
@ -224,6 +225,12 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps> = ({
refetch: refetchAll,
});
const { alertAssigneesItems, alertAssigneesPanels } = useAlertAssigneesActions({
closePopover,
ecsRowData,
refetch: refetchAll,
});
const items: AlertTableContextMenuItem[] = useMemo(
() =>
!isEvent && ruleId
@ -231,6 +238,7 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps> = ({
...addToCaseActionItems,
...statusActionItems,
...alertTagsItems,
...alertAssigneesItems,
...exceptionActionItems,
...(agentId ? osqueryActionItems : []),
]
@ -250,6 +258,7 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps> = ({
eventFilterActionItems,
canCreateEndpointEventFilters,
alertTagsItems,
alertAssigneesItems,
]
);
@ -260,8 +269,9 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps> = ({
items,
},
...alertTagsPanels,
...alertAssigneesPanels,
],
[alertTagsPanels, items]
[alertTagsPanels, alertAssigneesPanels, items]
);
const osqueryFlyout = useMemo(() => {

View file

@ -0,0 +1,203 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { TestProviders } from '@kbn/timelines-plugin/public/mock';
import { renderHook } from '@testing-library/react-hooks';
import type { UseAlertAssigneesActionsProps } from './use_alert_assignees_actions';
import { useAlertAssigneesActions } from './use_alert_assignees_actions';
import { useAlertsPrivileges } from '../../../containers/detection_engine/alerts/use_alerts_privileges';
import type { AlertTableContextMenuItem } from '../types';
import { render } from '@testing-library/react';
import React from 'react';
import type { EuiContextMenuPanelDescriptor } from '@elastic/eui';
import { EuiPopover, EuiContextMenu } from '@elastic/eui';
import { useSetAlertAssignees } from '../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees';
import { useGetCurrentUserProfile } from '../../../../common/components/user_profiles/use_get_current_user_profile';
import { useBulkGetUserProfiles } from '../../../../common/components/user_profiles/use_bulk_get_user_profiles';
import { useSuggestUsers } from '../../../../common/components/user_profiles/use_suggest_users';
import { useLicense } from '../../../../common/hooks/use_license';
jest.mock('../../../containers/detection_engine/alerts/use_alerts_privileges');
jest.mock('../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees');
jest.mock('../../../../common/components/user_profiles/use_get_current_user_profile');
jest.mock('../../../../common/components/user_profiles/use_bulk_get_user_profiles');
jest.mock('../../../../common/components/user_profiles/use_suggest_users');
jest.mock('../../../../common/hooks/use_license');
const mockUserProfiles = [
{ uid: 'user-id-1', enabled: true, user: { username: 'fakeUser1' }, data: {} },
{ uid: 'user-id-2', enabled: true, user: { username: 'fakeUser2' }, data: {} },
];
const defaultProps: UseAlertAssigneesActionsProps = {
closePopover: jest.fn(),
ecsRowData: {
_id: '123',
kibana: {
alert: {
workflow_assignee_ids: [],
},
},
},
refetch: jest.fn(),
};
const renderContextMenu = (
items: AlertTableContextMenuItem[],
panels: EuiContextMenuPanelDescriptor[]
) => {
const panelsToRender = [{ id: 0, items }, ...panels];
return render(
<EuiPopover
isOpen={true}
panelPaddingSize="none"
anchorPosition="downLeft"
closePopover={() => {}}
button={<></>}
>
<EuiContextMenu size="s" initialPanelId={2} panels={panelsToRender} />
</EuiPopover>
);
};
describe('useAlertAssigneesActions', () => {
beforeEach(() => {
(useAlertsPrivileges as jest.Mock).mockReturnValue({
hasIndexWrite: true,
});
(useLicense as jest.Mock).mockReturnValue({ isPlatinumPlus: () => true });
});
afterEach(() => {
jest.clearAllMocks();
});
it('should render alert assignees actions', () => {
const { result } = renderHook(() => useAlertAssigneesActions(defaultProps), {
wrapper: TestProviders,
});
expect(result.current.alertAssigneesItems.length).toEqual(2);
expect(result.current.alertAssigneesPanels.length).toEqual(1);
expect(result.current.alertAssigneesItems[0]['data-test-subj']).toEqual(
'alert-assignees-context-menu-item'
);
expect(result.current.alertAssigneesItems[1]['data-test-subj']).toEqual(
'remove-alert-assignees-menu-item'
);
expect(result.current.alertAssigneesPanels[0].content).toMatchInlineSnapshot(`
<Memo(BulkAlertAssigneesPanelComponent)
alertItems={
Array [
Object {
"_id": "123",
"_index": "",
"data": Array [
Object {
"field": "kibana.alert.workflow_assignee_ids",
"value": Array [],
},
],
"ecs": Object {
"_id": "123",
"_index": "",
},
},
]
}
closePopoverMenu={[MockFunction]}
onSubmit={[Function]}
refresh={[Function]}
setIsLoading={[Function]}
/>
`);
});
it("should not render alert assignees actions if user doesn't have write permissions", () => {
(useAlertsPrivileges as jest.Mock).mockReturnValue({
hasIndexWrite: false,
});
const { result } = renderHook(() => useAlertAssigneesActions(defaultProps), {
wrapper: TestProviders,
});
expect(result.current.alertAssigneesItems.length).toEqual(0);
});
it('should not render alert assignees actions within Basic license', () => {
(useLicense as jest.Mock).mockReturnValue({ isPlatinumPlus: () => false });
const { result } = renderHook(() => useAlertAssigneesActions(defaultProps), {
wrapper: TestProviders,
});
expect(result.current.alertAssigneesItems.length).toEqual(0);
});
it('should still render if workflow_assignee_ids field does not exist', () => {
const newProps = {
...defaultProps,
ecsRowData: {
_id: '123',
},
};
const { result } = renderHook(() => useAlertAssigneesActions(newProps), {
wrapper: TestProviders,
});
expect(result.current.alertAssigneesItems.length).toEqual(2);
expect(result.current.alertAssigneesPanels.length).toEqual(1);
expect(result.current.alertAssigneesPanels[0].content).toMatchInlineSnapshot(`
<Memo(BulkAlertAssigneesPanelComponent)
alertItems={
Array [
Object {
"_id": "123",
"_index": "",
"data": Array [
Object {
"field": "kibana.alert.workflow_assignee_ids",
"value": Array [],
},
],
"ecs": Object {
"_id": "123",
"_index": "",
},
},
]
}
closePopoverMenu={[MockFunction]}
onSubmit={[Function]}
refresh={[Function]}
setIsLoading={[Function]}
/>
`);
});
it('should render the nested panel', async () => {
(useSetAlertAssignees as jest.Mock).mockReturnValue(jest.fn());
(useGetCurrentUserProfile as jest.Mock).mockReturnValue({
isLoading: false,
data: mockUserProfiles[0],
});
(useBulkGetUserProfiles as jest.Mock).mockReturnValue({
isLoading: false,
data: mockUserProfiles,
});
(useSuggestUsers as jest.Mock).mockReturnValue({
isLoading: false,
data: mockUserProfiles,
});
const { result } = renderHook(() => useAlertAssigneesActions(defaultProps), {
wrapper: TestProviders,
});
const alertAssigneesItems = result.current.alertAssigneesItems;
const alertAssigneesPanels = result.current.alertAssigneesPanels;
const { getByTestId } = renderContextMenu(alertAssigneesItems, alertAssigneesPanels);
expect(getByTestId('alert-assignees-selectable-menu')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,94 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { noop } from 'lodash';
import { useCallback, useMemo } from 'react';
import type { EuiContextMenuPanelDescriptor } from '@elastic/eui';
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils';
import { ASSIGNEES_PANEL_WIDTH } from '../../../../common/components/assignees/constants';
import { useBulkAlertAssigneesItems } from '../../../../common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items';
import { useAlertsPrivileges } from '../../../containers/detection_engine/alerts/use_alerts_privileges';
import type { AlertTableContextMenuItem } from '../types';
export interface UseAlertAssigneesActionsProps {
closePopover: () => void;
ecsRowData: Ecs;
refetch?: () => void;
}
export const useAlertAssigneesActions = ({
closePopover,
ecsRowData,
refetch,
}: UseAlertAssigneesActionsProps) => {
const { hasIndexWrite } = useAlertsPrivileges();
const alertId = ecsRowData._id;
const alertAssigneeData = useMemo(() => {
return [
{
_id: alertId,
_index: ecsRowData._index ?? '',
data: [
{
field: ALERT_WORKFLOW_ASSIGNEE_IDS,
value: ecsRowData?.kibana?.alert.workflow_assignee_ids ?? [],
},
],
ecs: {
_id: alertId,
_index: ecsRowData._index ?? '',
},
},
];
}, [alertId, ecsRowData._index, ecsRowData?.kibana?.alert.workflow_assignee_ids]);
const onAssigneesUpdate = useCallback(() => {
closePopover();
if (refetch) {
refetch();
}
}, [closePopover, refetch]);
const { alertAssigneesItems, alertAssigneesPanels } = useBulkAlertAssigneesItems({
onAssigneesUpdate,
});
const itemsToReturn: AlertTableContextMenuItem[] = useMemo(
() =>
alertAssigneesItems.map((item) => ({
name: item.name,
panel: item.panel,
'data-test-subj': item['data-test-subj'],
key: item.key,
onClick: () => item.onClick?.(alertAssigneeData, false, noop, noop, noop),
})),
[alertAssigneeData, alertAssigneesItems]
);
const panelsToReturn: EuiContextMenuPanelDescriptor[] = useMemo(
() =>
alertAssigneesPanels.map((panel) => {
const content = panel.renderContent({
closePopoverMenu: closePopover,
setIsBulkActionsLoading: () => {},
alertItems: alertAssigneeData,
refresh: onAssigneesUpdate,
});
return { title: panel.title, content, id: panel.id, width: ASSIGNEES_PANEL_WIDTH };
}),
[alertAssigneeData, alertAssigneesPanels, closePopover, onAssigneesUpdate]
);
return {
alertAssigneesItems: hasIndexWrite ? itemsToReturn : [],
alertAssigneesPanels: panelsToReturn,
};
};

View file

@ -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',
{

View file

@ -23,6 +23,35 @@ jest.mock('../../../common/components/filter_group');
jest.mock('../../../common/lib/kibana');
const mockUserProfiles = [
{
uid: 'user-id-1',
enabled: true,
user: { username: 'user1', full_name: 'User 1', email: 'user1@test.com' },
data: {},
},
{
uid: 'user-id-2',
enabled: true,
user: { username: 'user2', full_name: 'User 2', email: 'user2@test.com' },
data: {},
},
{
uid: 'user-id-3',
enabled: true,
user: { username: 'user3', full_name: 'User 3', email: 'user3@test.com' },
data: {},
},
];
jest.mock('../../../common/components/user_profiles/use_suggest_users', () => {
return {
useSuggestUsers: () => ({
loading: false,
userProfiles: mockUserProfiles,
}),
};
});
const basicKibanaServicesMock = createStartServicesMock();
const getFieldByNameMock = jest.fn(() => true);

View file

@ -37,7 +37,10 @@ import { getUserPrivilegesMockDefaultValue } from '../../../common/components/us
import { allCasesPermissions } from '../../../cases_test_utils';
import { HostStatus } from '../../../../common/endpoint/types';
import { ENDPOINT_CAPABILITIES } from '../../../../common/endpoint/service/response_actions/constants';
import { ALERT_TAGS_CONTEXT_MENU_ITEM_TITLE } from '../../../common/components/toolbar/bulk_actions/translations';
import {
ALERT_ASSIGNEES_CONTEXT_MENU_ITEM_TITLE,
ALERT_TAGS_CONTEXT_MENU_ITEM_TITLE,
} from '../../../common/components/toolbar/bulk_actions/translations';
jest.mock('../../../common/components/user_privileges');
@ -58,6 +61,10 @@ jest.mock('../../../common/hooks/use_app_toasts', () => ({
}),
}));
jest.mock('../../../common/hooks/use_license', () => ({
useLicense: jest.fn().mockReturnValue({ isPlatinumPlus: () => true }),
}));
jest.mock('../../../common/hooks/use_experimental_features', () => ({
useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true),
}));
@ -254,6 +261,13 @@ describe('take action dropdown', () => {
).toEqual(ALERT_TAGS_CONTEXT_MENU_ITEM_TITLE);
});
});
test('should render "Assign alert"', async () => {
await waitFor(() => {
expect(
wrapper.find('[data-test-subj="alert-assignees-context-menu-item"]').first().text()
).toEqual(ALERT_ASSIGNEES_CONTEXT_MENU_ITEM_TITLE);
});
});
});
describe('for Endpoint related actions', () => {

View file

@ -35,6 +35,7 @@ import { useKibana } from '../../../common/lib/kibana';
import { getOsqueryActionItem } from '../osquery/osquery_action_item';
import type { AlertTableContextMenuItem } from '../alerts_table/types';
import { useAlertTagsActions } from '../alerts_table/timeline_actions/use_alert_tags_actions';
import { useAlertAssigneesActions } from '../alerts_table/timeline_actions/use_alert_assignees_actions';
interface ActionsData {
alertStatus: Status;
@ -189,6 +190,20 @@ export const TakeActionDropdown = React.memo(
refetch,
});
const onAssigneesUpdate = useCallback(() => {
if (refetch) {
refetch();
}
if (refetchFlyoutData) {
refetchFlyoutData();
}
}, [refetch, refetchFlyoutData]);
const { alertAssigneesItems, alertAssigneesPanels } = useAlertAssigneesActions({
closePopover: closePopoverHandler,
ecsRowData: ecsData ?? { _id: actionsData.eventId },
refetch: onAssigneesUpdate,
});
const { investigateInTimelineActionItems } = useInvestigateInTimeline({
ecsRowData: ecsData,
onInvestigateInTimelineAlertClick: closePopoverHandler,
@ -214,7 +229,12 @@ export const TakeActionDropdown = React.memo(
const alertsActionItems = useMemo(
() =>
!isEvent && actionsData.ruleId
? [...statusActionItems, ...alertTagsItems, ...exceptionActionItems]
? [
...statusActionItems,
...alertTagsItems,
...alertAssigneesItems,
...exceptionActionItems,
]
: isEndpointEvent && canCreateEndpointEventFilters
? eventFilterActionItems
: [],
@ -227,6 +247,7 @@ export const TakeActionDropdown = React.memo(
isEvent,
actionsData.ruleId,
alertTagsItems,
alertAssigneesItems,
]
);
@ -271,6 +292,7 @@ export const TakeActionDropdown = React.memo(
items,
},
...alertTagsPanels,
...alertAssigneesPanels,
];
const takeActionButton = useMemo(

View file

@ -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,

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useMemo } from 'react';
import type { UserProfileWithAvatar } from '@kbn/user-profile-components';
import type { PreFetchPageContext } from '@kbn/triggers-actions-ui-plugin/public/types';
import { useBulkGetUserProfiles } from '../../../common/components/user_profiles/use_bulk_get_user_profiles';
export interface RenderCellValueContext {
profiles: UserProfileWithAvatar[] | undefined;
isLoading: boolean;
}
// Add new columns names to this array to render the user's display name instead of profile_uid
export const profileUidColumns = [
'kibana.alert.workflow_assignee_ids',
'kibana.alert.workflow_user',
];
export const useFetchPageContext: PreFetchPageContext<RenderCellValueContext> = ({
alerts,
columns,
}) => {
const uids = new Set<string>();
alerts.forEach((alert) => {
profileUidColumns.forEach((columnId) => {
if (columns.find((column) => column.id === columnId) != null) {
const userUids = alert[columnId];
userUids?.forEach((uid) => uids.add(uid as string));
}
});
});
const result = useBulkGetUserProfiles({ uids });
const returnVal = useMemo(
() => ({ profiles: result.data, isLoading: result.isLoading }),
[result.data, result.isLoading]
);
return returnVal;
};

View file

@ -33,6 +33,7 @@ import { SUPPRESSED_ALERT_TOOLTIP } from './translations';
import { VIEW_SELECTION } from '../../../../common/constants';
import { getAllFieldsByName } from '../../../common/containers/source';
import { eventRenderedViewColumns, getColumns } from './columns';
import type { RenderCellValueContext } from './fetch_page_context';
/**
* This implementation of `EuiDataGrid`'s `renderCellValue`
@ -95,7 +96,7 @@ export const getRenderCellValueHook = ({
scopeId: SourcererScopeName;
tableId: TableId;
}) => {
const useRenderCellValue: GetRenderCellValue = () => {
const useRenderCellValue: GetRenderCellValue = ({ context }) => {
const { browserFields } = useSourcererDataView(scopeId);
const browserFieldsByName = useMemo(() => getAllFieldsByName(browserFields), [browserFields]);
const getTable = useMemo(() => dataTableSelectors.getTableByIdSelector(), []);
@ -173,10 +174,11 @@ export const getRenderCellValueHook = ({
scopeId={tableId}
truncate={truncate}
asPlainText={false}
context={context as RenderCellValueContext}
/>
);
},
[browserFieldsByName, browserFields, columnHeaders]
[browserFieldsByName, columnHeaders, browserFields, context]
);
return result;
};

View file

@ -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' }
);

View file

@ -12,6 +12,7 @@ import { isEqual } from 'lodash';
import type { Filter } from '@kbn/es-query';
import { useCallback } from 'react';
import type { TableId } from '@kbn/securitysolution-data-table';
import { useBulkAlertAssigneesItems } from '../../../common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items';
import { useBulkAlertTagsItems } from '../../../common/components/toolbar/bulk_actions/use_bulk_alert_tags_items';
import type { inputsModel, State } from '../../../common/store';
import { useShallowEqualSelector } from '../../../common/hooks/use_selector';
@ -93,7 +94,11 @@ export const getBulkActionHook =
refetch: refetchGlobalQuery,
});
const items = [...alertActions, timelineAction, ...alertTagsItems];
const { alertAssigneesItems, alertAssigneesPanels } = useBulkAlertAssigneesItems({
onAssigneesUpdate: refetchGlobalQuery,
});
return [{ id: 0, items }, ...alertTagsPanels];
const items = [...alertActions, timelineAction, ...alertTagsItems, ...alertAssigneesItems];
return [{ id: 0, items }, ...alertTagsPanels, ...alertAssigneesPanels];
};

View file

@ -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 <span />;
});
(useUpsellingMessage as jest.Mock).mockReturnValue('Go for Platinum!');
});
beforeEach(() => {
jest.clearAllMocks();

View file

@ -31,6 +31,9 @@ import {
tableDefaults,
TableId,
} from '@kbn/securitysolution-data-table';
import { isEqual } from 'lodash';
import { FilterByAssigneesPopover } from '../../../common/components/filter_group/filter_by_assignees';
import type { AssigneesIdsSelection } from '../../../common/components/assignees/types';
import { ALERTS_TABLE_REGISTRY_CONFIG_IDS } from '../../../../common/constants';
import { useDataTableFilters } from '../../../common/hooks/use_data_table_filters';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
@ -62,6 +65,7 @@ import {
showGlobalFilters,
} from '../../../timelines/components/timeline/helpers';
import {
buildAlertAssigneesFilter,
buildAlertStatusFilter,
buildShowBuildingBlockFilter,
buildThreatMatchFilter,
@ -135,6 +139,16 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
const { loading: listsConfigLoading, needsConfiguration: needsListsConfiguration } =
useListsConfig();
const [assignees, setAssignees] = useState<AssigneesIdsSelection[]>([]);
const handleSelectedAssignees = useCallback(
(newAssignees: AssigneesIdsSelection[]) => {
if (!isEqual(newAssignees, assignees)) {
setAssignees(newAssignees);
}
},
[assignees]
);
const arePageFiltersEnabled = useIsExperimentalFeatureEnabled('alertsPageFiltersEnabled');
// when arePageFiltersEnabled === false
@ -176,8 +190,9 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
...filters,
...buildShowBuildingBlockFilter(showBuildingBlockAlerts),
...buildThreatMatchFilter(showOnlyThreatIndicatorAlerts),
...buildAlertAssigneesFilter(assignees),
];
}, [showBuildingBlockAlerts, showOnlyThreatIndicatorAlerts, filters]);
}, [assignees, showBuildingBlockAlerts, showOnlyThreatIndicatorAlerts, filters]);
const alertPageFilters = useMemo(() => {
if (arePageFiltersEnabled) {
@ -247,8 +262,9 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
...buildShowBuildingBlockFilter(showBuildingBlockAlerts),
...buildThreatMatchFilter(showOnlyThreatIndicatorAlerts),
...(alertPageFilters ?? []),
...buildAlertAssigneesFilter(assignees),
],
[showBuildingBlockAlerts, showOnlyThreatIndicatorAlerts, alertPageFilters]
[assignees, showBuildingBlockAlerts, showOnlyThreatIndicatorAlerts, alertPageFilters]
);
const { signalIndexNeedsInit, pollForSignalIndex } = useSignalHelpers();
@ -363,16 +379,16 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
/>
),
[
topLevelFilters,
arePageFiltersEnabled,
statusFilter,
from,
onFilterGroupChangedCallback,
pageFiltersUpdateHandler,
showUpdating,
from,
query,
showUpdating,
statusFilter,
timelinesUi,
to,
topLevelFilters,
updatedAt,
]
);
@ -448,14 +464,24 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
>
<Display show={!globalFullScreen}>
<HeaderPage title={i18n.PAGE_TITLE}>
<SecuritySolutionLinkButton
onClick={goToRules}
deepLinkId={SecurityPageName.rules}
data-test-subj="manage-alert-detection-rules"
fill
>
{i18n.BUTTON_MANAGE_RULES}
</SecuritySolutionLinkButton>
<EuiFlexGroup gutterSize="m">
<EuiFlexItem>
<FilterByAssigneesPopover
assignedUserIds={assignees}
onSelectionChange={handleSelectedAssignees}
/>
</EuiFlexItem>
<EuiFlexItem>
<SecuritySolutionLinkButton
onClick={goToRules}
deepLinkId={SecurityPageName.rules}
data-test-subj="manage-alert-detection-rules"
fill
>
{i18n.BUTTON_MANAGE_RULES}
</SecuritySolutionLinkButton>
</EuiFlexItem>
</EuiFlexGroup>
</HeaderPage>
<EuiHorizontalRule margin="none" />
<EuiSpacer size="l" />

View file

@ -0,0 +1,166 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { ASSIGNEES_ADD_BUTTON_TEST_ID, ASSIGNEES_TITLE_TEST_ID } from './test_ids';
import { Assignees } from './assignees';
import { useGetCurrentUserProfile } from '../../../../common/components/user_profiles/use_get_current_user_profile';
import { useBulkGetUserProfiles } from '../../../../common/components/user_profiles/use_bulk_get_user_profiles';
import { useSuggestUsers } from '../../../../common/components/user_profiles/use_suggest_users';
import type { SetAlertAssigneesFunc } from '../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees';
import { useSetAlertAssignees } from '../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees';
import { TestProviders } from '../../../../common/mock';
import { ASSIGNEES_APPLY_BUTTON_TEST_ID } from '../../../../common/components/assignees/test_ids';
import { useLicense } from '../../../../common/hooks/use_license';
import { useUpsellingMessage } from '../../../../common/hooks/use_upselling';
import {
USERS_AVATARS_COUNT_BADGE_TEST_ID,
USERS_AVATARS_PANEL_TEST_ID,
USER_AVATAR_ITEM_TEST_ID,
} from '../../../../common/components/user_profiles/test_ids';
import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges';
jest.mock('../../../../common/components/user_profiles/use_get_current_user_profile');
jest.mock('../../../../common/components/user_profiles/use_bulk_get_user_profiles');
jest.mock('../../../../common/components/user_profiles/use_suggest_users');
jest.mock('../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees');
jest.mock('../../../../common/hooks/use_license');
jest.mock('../../../../common/hooks/use_upselling');
jest.mock('../../../../detections/containers/detection_engine/alerts/use_alerts_privileges');
const mockUserProfiles = [
{ uid: 'user-id-1', enabled: true, user: { username: 'user1', full_name: 'User 1' }, data: {} },
{ uid: 'user-id-2', enabled: true, user: { username: 'user2', full_name: 'User 2' }, data: {} },
{ uid: 'user-id-3', enabled: true, user: { username: 'user3', full_name: 'User 3' }, data: {} },
];
const renderAssignees = (
eventId = 'event-1',
alertAssignees = ['user-id-1'],
onAssigneesUpdated = jest.fn()
) => {
const assignedProfiles = mockUserProfiles.filter((user) => alertAssignees.includes(user.uid));
(useBulkGetUserProfiles as jest.Mock).mockReturnValue({
isLoading: false,
data: assignedProfiles,
});
return render(
<TestProviders>
<Assignees
eventId={eventId}
assignedUserIds={alertAssignees}
onAssigneesUpdated={onAssigneesUpdated}
/>
</TestProviders>
);
};
describe('<Assignees />', () => {
let setAlertAssigneesMock: jest.Mocked<SetAlertAssigneesFunc>;
beforeEach(() => {
jest.clearAllMocks();
(useGetCurrentUserProfile as jest.Mock).mockReturnValue({
isLoading: false,
data: mockUserProfiles[0],
});
(useSuggestUsers as jest.Mock).mockReturnValue({
isLoading: false,
data: mockUserProfiles,
});
(useAlertsPrivileges as jest.Mock).mockReturnValue({ hasIndexWrite: true });
(useLicense as jest.Mock).mockReturnValue({ isPlatinumPlus: () => true });
(useUpsellingMessage as jest.Mock).mockReturnValue('Go for Platinum!');
setAlertAssigneesMock = jest.fn().mockReturnValue(Promise.resolve());
(useSetAlertAssignees as jest.Mock).mockReturnValue(setAlertAssigneesMock);
});
it('should render component', () => {
const { getByTestId } = renderAssignees();
expect(getByTestId(ASSIGNEES_TITLE_TEST_ID)).toBeInTheDocument();
expect(getByTestId(USERS_AVATARS_PANEL_TEST_ID)).toBeInTheDocument();
expect(getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID)).toBeInTheDocument();
expect(getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID)).not.toBeDisabled();
});
it('should render assignees avatars', () => {
const assignees = ['user-id-1', 'user-id-2'];
const { getByTestId, queryByTestId } = renderAssignees('test-event', assignees);
expect(getByTestId(USER_AVATAR_ITEM_TEST_ID('user1'))).toBeInTheDocument();
expect(getByTestId(USER_AVATAR_ITEM_TEST_ID('user2'))).toBeInTheDocument();
expect(queryByTestId(USERS_AVATARS_COUNT_BADGE_TEST_ID)).not.toBeInTheDocument();
});
it('should render badge with assignees count in case there are more than two users assigned to an alert', () => {
const assignees = ['user-id-1', 'user-id-2', 'user-id-3'];
const { getByTestId, queryByTestId } = renderAssignees('test-event', assignees);
const assigneesCountBadge = getByTestId(USERS_AVATARS_COUNT_BADGE_TEST_ID);
expect(assigneesCountBadge).toBeInTheDocument();
expect(assigneesCountBadge).toHaveTextContent(`${assignees.length}`);
expect(queryByTestId(USER_AVATAR_ITEM_TEST_ID('user1'))).not.toBeInTheDocument();
expect(queryByTestId(USER_AVATAR_ITEM_TEST_ID('user2'))).not.toBeInTheDocument();
expect(queryByTestId(USER_AVATAR_ITEM_TEST_ID('user3'))).not.toBeInTheDocument();
});
it('should call assignees update functionality with the right arguments', () => {
const assignedProfiles = [mockUserProfiles[0], mockUserProfiles[1]];
(useBulkGetUserProfiles as jest.Mock).mockReturnValue({
isLoading: false,
data: assignedProfiles,
});
const assignees = assignedProfiles.map((assignee) => assignee.uid);
const { getByTestId, getByText } = renderAssignees('test-event', assignees);
// Update assignees
getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID).click();
getByText('User 1').click();
getByText('User 3').click();
// Apply assignees
getByTestId(ASSIGNEES_APPLY_BUTTON_TEST_ID).click();
expect(setAlertAssigneesMock).toHaveBeenCalledWith(
{
add: ['user-id-3'],
remove: ['user-id-1'],
},
['test-event'],
expect.anything(),
expect.anything()
);
});
it('should render add assignees button as disabled if user has readonly priviliges', () => {
(useAlertsPrivileges as jest.Mock).mockReturnValue({ hasIndexWrite: false });
const assignees = ['user-id-1', 'user-id-2'];
const { getByTestId } = renderAssignees('test-event', assignees);
expect(getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID)).toBeInTheDocument();
expect(getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID)).toBeDisabled();
});
it('should render add assignees button as disabled within Basic license', () => {
(useLicense as jest.Mock).mockReturnValue({ isPlatinumPlus: () => false });
const assignees = ['user-id-1', 'user-id-2'];
const { getByTestId } = renderAssignees('test-event', assignees);
expect(getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID)).toBeInTheDocument();
expect(getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID)).toBeDisabled();
});
});

View file

@ -0,0 +1,159 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { noop } from 'lodash';
import type { FC } from 'react';
import React, { memo, useCallback, useMemo, useState } from 'react';
import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiTitle, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { useUpsellingMessage } from '../../../../common/hooks/use_upselling';
import { useLicense } from '../../../../common/hooks/use_license';
import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges';
import { useBulkGetUserProfiles } from '../../../../common/components/user_profiles/use_bulk_get_user_profiles';
import { removeNoAssigneesSelection } from '../../../../common/components/assignees/utils';
import type { AssigneesIdsSelection } from '../../../../common/components/assignees/types';
import { AssigneesPopover } from '../../../../common/components/assignees/assignees_popover';
import { UsersAvatarsPanel } from '../../../../common/components/user_profiles/users_avatars_panel';
import { useSetAlertAssignees } from '../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees';
import {
ASSIGNEES_ADD_BUTTON_TEST_ID,
ASSIGNEES_HEADER_TEST_ID,
ASSIGNEES_TITLE_TEST_ID,
} from './test_ids';
const UpdateAssigneesButton: FC<{
isDisabled: boolean;
toolTipMessage: string;
togglePopover: () => void;
}> = memo(({ togglePopover, isDisabled, toolTipMessage }) => (
<EuiToolTip position="bottom" content={toolTipMessage}>
<EuiButtonIcon
aria-label="Update assignees"
data-test-subj={ASSIGNEES_ADD_BUTTON_TEST_ID}
iconType={'plusInCircle'}
onClick={togglePopover}
isDisabled={isDisabled}
/>
</EuiToolTip>
));
UpdateAssigneesButton.displayName = 'UpdateAssigneesButton';
export interface AssigneesProps {
/**
* Id of the document
*/
eventId: string;
/**
* The array of ids of the users assigned to the alert
*/
assignedUserIds: string[];
/**
* Callback to handle the successful assignees update
*/
onAssigneesUpdated?: () => void;
}
/**
* Document assignees details displayed in flyout right section header
*/
export const Assignees: FC<AssigneesProps> = memo(
({ eventId, assignedUserIds, onAssigneesUpdated }) => {
const isPlatinumPlus = useLicense().isPlatinumPlus();
const upsellingMessage = useUpsellingMessage('alert_assignments');
const { hasIndexWrite } = useAlertsPrivileges();
const setAlertAssignees = useSetAlertAssignees();
const uids = useMemo(() => new Set(assignedUserIds), [assignedUserIds]);
const { data: assignedUsers } = useBulkGetUserProfiles({ uids });
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const onSuccess = useCallback(() => {
if (onAssigneesUpdated) onAssigneesUpdated();
}, [onAssigneesUpdated]);
const togglePopover = useCallback(() => {
setIsPopoverOpen((value) => !value);
}, []);
const onAssigneesApply = useCallback(
async (assigneesIds: AssigneesIdsSelection[]) => {
setIsPopoverOpen(false);
if (setAlertAssignees) {
const updatedIds = removeNoAssigneesSelection(assigneesIds);
const assigneesToAddArray = updatedIds.filter((uid) => !assignedUserIds.includes(uid));
const assigneesToRemoveArray = assignedUserIds.filter((uid) => !updatedIds.includes(uid));
const assigneesToUpdate = {
add: assigneesToAddArray,
remove: assigneesToRemoveArray,
};
await setAlertAssignees(assigneesToUpdate, [eventId], onSuccess, noop);
}
},
[assignedUserIds, eventId, onSuccess, setAlertAssignees]
);
return (
<EuiFlexGroup
data-test-subj={ASSIGNEES_HEADER_TEST_ID}
alignItems="center"
direction="row"
gutterSize="xs"
responsive={false}
>
<EuiFlexItem grow={false}>
<EuiTitle size="xxs" data-test-subj={ASSIGNEES_TITLE_TEST_ID}>
<h3>
<FormattedMessage
id="xpack.securitySolution.flyout.right.header.assignedTitle"
defaultMessage="Assignees:"
/>
</h3>
</EuiTitle>
</EuiFlexItem>
{assignedUsers && (
<EuiFlexItem grow={false}>
<UsersAvatarsPanel userProfiles={assignedUsers} maxVisibleAvatars={2} />
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<AssigneesPopover
assignedUserIds={assignedUserIds}
button={
<UpdateAssigneesButton
togglePopover={togglePopover}
isDisabled={!hasIndexWrite || !isPlatinumPlus}
toolTipMessage={
upsellingMessage ??
i18n.translate(
'xpack.securitySolution.flyout.right.visualizations.assignees.popoverTooltip',
{
defaultMessage: 'Assign alert',
}
)
}
/>
}
isPopoverOpen={isPopoverOpen}
closePopover={togglePopover}
onAssigneesApply={onAssigneesApply}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}
);
Assignees.displayName = 'Assignees';

View file

@ -6,26 +6,36 @@
*/
import type { FC } from 'react';
import React, { memo, useMemo } from 'react';
import React, { memo, useCallback, useMemo } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui';
import { isEmpty } from 'lodash';
import { FormattedMessage } from '@kbn/i18n-react';
import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils';
import { DocumentStatus } from './status';
import { DocumentSeverity } from './severity';
import { RiskScore } from './risk_score';
import { useRefetchByScope } from '../../../../timelines/components/side_panel/event_details/flyout/use_refetch_by_scope';
import { useBasicDataFromDetailsData } from '../../../../timelines/components/side_panel/event_details/helpers';
import { useRightPanelContext } from '../context';
import { PreferenceFormattedDate } from '../../../../common/components/formatted_date';
import { RenderRuleName } from '../../../../timelines/components/timeline/body/renderers/formatted_field_helpers';
import { SIGNAL_RULE_NAME_FIELD_NAME } from '../../../../timelines/components/timeline/body/renderers/constants';
import { FLYOUT_HEADER_TITLE_TEST_ID } from './test_ids';
import { Assignees } from './assignees';
import { FlyoutTitle } from '../../../shared/components/flyout_title';
/**
* Document details flyout right section header
*/
export const HeaderTitle: FC = memo(() => {
const { dataFormattedForFieldBrowser, eventId, scopeId, isPreview } = useRightPanelContext();
const {
dataFormattedForFieldBrowser,
eventId,
scopeId,
isPreview,
refetchFlyoutData,
getFieldsData,
} = useRightPanelContext();
const { isAlert, ruleName, timestamp, ruleId } = useBasicDataFromDetailsData(
dataFormattedForFieldBrowser
);
@ -72,6 +82,16 @@ export const HeaderTitle: FC = memo(() => {
</EuiTitle>
);
const { refetch } = useRefetchByScope({ scopeId });
const alertAssignees = useMemo(
() => (getFieldsData(ALERT_WORKFLOW_ASSIGNEE_IDS) as string[]) ?? [],
[getFieldsData]
);
const onAssigneesUpdated = useCallback(() => {
refetch();
refetchFlyoutData();
}, [refetch, refetchFlyoutData]);
return (
<>
<DocumentSeverity />
@ -91,6 +111,15 @@ export const HeaderTitle: FC = memo(() => {
<EuiFlexItem grow={false}>
<RiskScore />
</EuiFlexItem>
{isAlert && !isPreview && (
<EuiFlexItem grow={false}>
<Assignees
eventId={eventId}
assignedUserIds={alertAssignees}
onAssigneesUpdated={onAssigneesUpdated}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</>
);

View file

@ -19,6 +19,10 @@ export const RISK_SCORE_VALUE_TEST_ID = `${FLYOUT_HEADER_TEST_ID}RiskScoreValue`
export const SHARE_BUTTON_TEST_ID = `${FLYOUT_HEADER_TEST_ID}ShareButton` as const;
export const CHAT_BUTTON_TEST_ID = 'newChatById' as const;
export const ASSIGNEES_HEADER_TEST_ID = `${FLYOUT_HEADER_TEST_ID}AssigneesHeader` as const;
export const ASSIGNEES_TITLE_TEST_ID = `${FLYOUT_HEADER_TEST_ID}AssigneesTitle` as const;
export const ASSIGNEES_ADD_BUTTON_TEST_ID = `${FLYOUT_HEADER_TEST_ID}AssigneesAddButton` as const;
/* About section */
export const ABOUT_SECTION_TEST_ID = `${PREFIX}AboutSection` as const;

View file

@ -50,7 +50,7 @@ Array [
class="euiFlexItem emotion-euiFlexItem-growZero"
>
<div
class="euiFlexGroup emotion-euiFlexGroup-l-flexStart-flexEnd-column"
class="euiFlexGroup emotion-euiFlexGroup-none-flexStart-flexEnd-column"
>
<div
class="euiFlexItem emotion-euiFlexItem-growZero"
@ -75,6 +75,63 @@ Array [
class="euiFlexGroup emotion-euiFlexGroup-responsive-none-flexStart-center-row"
/>
</div>
<div
class="euiFlexItem emotion-euiFlexItem-growZero"
>
<div
class="euiFlexGroup emotion-euiFlexGroup-xs-flexStart-center-row"
data-test-subj="securitySolutionFlyoutHeaderAssigneesHeader"
>
<div
class="euiFlexItem emotion-euiFlexItem-growZero"
>
<h3
class="euiTitle emotion-euiTitle-xxs"
data-test-subj="securitySolutionFlyoutHeaderAssigneesTitle"
>
Assignees:
</h3>
</div>
<div
class="euiFlexItem emotion-euiFlexItem-growZero"
>
<div
class="euiFlexGroup emotion-euiFlexGroup-responsive-xs-flexStart-center-row"
data-test-subj="securitySolutionUsersAvatarsPanel"
/>
</div>
<div
class="euiFlexItem emotion-euiFlexItem-growZero"
>
<div
class="euiPopover emotion-euiPopover"
>
<div
class="euiPopover__anchor css-16vtueo-render"
>
<span
class="euiToolTipAnchor emotion-euiToolTipAnchor-inlineBlock"
>
<button
aria-label="Update assignees"
class="euiButtonIcon emotion-euiButtonIcon-xs-empty-disabled-isDisabled"
data-test-subj="securitySolutionFlyoutHeaderAssigneesAddButton"
disabled=""
type="button"
>
<span
aria-hidden="true"
class="euiButtonIcon__icon"
color="inherit"
data-euiicon-type="plusInCircle"
/>
</button>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>,
@ -199,7 +256,7 @@ exports[`Details Panel Component DetailsPanel:EventDetails: rendering it should
class="euiFlexItem emotion-euiFlexItem-growZero"
>
<div
class="euiFlexGroup emotion-euiFlexGroup-l-flexStart-flexEnd-column"
class="euiFlexGroup emotion-euiFlexGroup-none-flexStart-flexEnd-column"
>
<div
class="euiFlexItem emotion-euiFlexItem-growZero"
@ -208,6 +265,63 @@ exports[`Details Panel Component DetailsPanel:EventDetails: rendering it should
class="euiFlexGroup emotion-euiFlexGroup-responsive-none-flexStart-center-row"
/>
</div>
<div
class="euiFlexItem emotion-euiFlexItem-growZero"
>
<div
class="euiFlexGroup emotion-euiFlexGroup-xs-flexStart-center-row"
data-test-subj="securitySolutionFlyoutHeaderAssigneesHeader"
>
<div
class="euiFlexItem emotion-euiFlexItem-growZero"
>
<h3
class="euiTitle emotion-euiTitle-xxs"
data-test-subj="securitySolutionFlyoutHeaderAssigneesTitle"
>
Assignees:
</h3>
</div>
<div
class="euiFlexItem emotion-euiFlexItem-growZero"
>
<div
class="euiFlexGroup emotion-euiFlexGroup-responsive-xs-flexStart-center-row"
data-test-subj="securitySolutionUsersAvatarsPanel"
/>
</div>
<div
class="euiFlexItem emotion-euiFlexItem-growZero"
>
<div
class="euiPopover emotion-euiPopover"
>
<div
class="euiPopover__anchor css-16vtueo-render"
>
<span
class="euiToolTipAnchor emotion-euiToolTipAnchor-inlineBlock"
>
<button
aria-label="Update assignees"
class="euiButtonIcon emotion-euiButtonIcon-xs-empty-disabled-isDisabled"
data-test-subj="securitySolutionFlyoutHeaderAssigneesAddButton"
disabled=""
type="button"
>
<span
aria-hidden="true"
class="euiButtonIcon__icon"
color="inherit"
data-euiicon-type="plusInCircle"
/>
</button>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View file

@ -19,9 +19,13 @@ import {
EuiSpacer,
EuiCopy,
} from '@elastic/eui';
import React from 'react';
import React, { useCallback, useMemo } from 'react';
import styled from 'styled-components';
import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils';
import { TableId } from '@kbn/securitysolution-data-table';
import type { GetFieldsData } from '../../../../common/hooks/use_get_fields_data';
import { Assignees } from '../../../../flyout/document_details/right/components/assignees';
import { useAssistantAvailability } from '../../../../assistant/use_assistant_availability';
import type { TimelineTabs } from '../../../../../common/types/timeline';
import type { BrowserFields } from '../../../../common/containers/source';
@ -34,6 +38,7 @@ import {
} from '../../../../common/components/event_details/translations';
import { PreferenceFormattedDate } from '../../../../common/components/formatted_date';
import { useGetAlertDetailsFlyoutLink } from './use_get_alert_details_flyout_link';
import { useRefetchByScope } from './flyout/use_refetch_by_scope';
export type HandleOnEventClosed = () => void;
interface Props {
@ -61,6 +66,9 @@ interface ExpandableEventTitleProps {
ruleName?: string;
timestamp: string;
handleOnEventClosed?: HandleOnEventClosed;
scopeId: string;
refetchFlyoutData: () => Promise<void>;
getFieldsData: GetFieldsData;
}
const StyledEuiFlexGroup = styled(EuiFlexGroup)`
@ -89,6 +97,9 @@ export const ExpandableEventTitle = React.memo<ExpandableEventTitleProps>(
promptContextId,
ruleName,
timestamp,
scopeId,
refetchFlyoutData,
getFieldsData,
}) => {
const { hasAssistantPrivilege } = useAssistantAvailability();
const alertDetailsLink = useGetAlertDetailsFlyoutLink({
@ -97,6 +108,16 @@ export const ExpandableEventTitle = React.memo<ExpandableEventTitleProps>(
timestamp,
});
const { refetch } = useRefetchByScope({ scopeId });
const alertAssignees = useMemo(
() => (getFieldsData(ALERT_WORKFLOW_ASSIGNEE_IDS) as string[]) ?? [],
[getFieldsData]
);
const onAssigneesUpdated = useCallback(() => {
refetch();
refetchFlyoutData();
}, [refetch, refetchFlyoutData]);
return (
<StyledEuiFlexGroup gutterSize="none" justifyContent="spaceBetween" wrap={true}>
<EuiFlexItem grow={false}>
@ -115,7 +136,7 @@ export const ExpandableEventTitle = React.memo<ExpandableEventTitleProps>(
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup direction="column" alignItems="flexEnd">
<EuiFlexGroup direction="column" alignItems="flexEnd" gutterSize="none">
{handleOnEventClosed && (
<EuiFlexItem grow={false}>
<EuiButtonIcon
@ -154,6 +175,15 @@ export const ExpandableEventTitle = React.memo<ExpandableEventTitleProps>(
)}
</EuiFlexGroup>
</EuiFlexItem>
{scopeId !== TableId.rulePreview && (
<EuiFlexItem grow={false}>
<Assignees
eventId={eventId}
assignedUserIds={alertAssignees}
onAssigneesUpdated={onAssigneesUpdated}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
</StyledEuiFlexGroup>

View file

@ -8,8 +8,6 @@
import React, { useCallback, useMemo, useState } from 'react';
import { EuiFlyoutFooter, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { find } from 'lodash/fp';
import type { ConnectedProps } from 'react-redux';
import { connect } from 'react-redux';
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import { isActiveTimeline } from '../../../../../helpers';
import { TakeActionDropdown } from '../../../../../detections/components/take_action_dropdown';
@ -20,9 +18,9 @@ import { EventFiltersFlyout } from '../../../../../management/pages/event_filter
import { useEventFilterModal } from '../../../../../detections/components/alerts_table/timeline_actions/use_event_filter_modal';
import { getFieldValue } from '../../../../../detections/components/host_isolation/helpers';
import type { Status } from '../../../../../../common/api/detection_engine';
import type { inputsModel, State } from '../../../../../common/store';
import { inputsSelectors } from '../../../../../common/store';
import { OsqueryFlyout } from '../../../../../detections/components/osquery/osquery_flyout';
import { useRefetchByScope } from './use_refetch_by_scope';
interface FlyoutFooterProps {
detailsData: TimelineEventsDetailsItem[] | null;
detailsEcsData: Ecs | null;
@ -43,176 +41,142 @@ interface AddExceptionModalWrapperData {
ruleName: string;
}
// eslint-disable-next-line react/display-name
export const FlyoutFooterComponent = React.memo(
({
detailsData,
detailsEcsData,
handleOnEventClosed,
isHostIsolationPanelOpen,
isReadOnly,
loadingEventDetails,
onAddIsolationStatusClick,
scopeId,
globalQuery,
timelineQuery,
refetchFlyoutData,
}: FlyoutFooterProps & PropsFromRedux) => {
const alertId = detailsEcsData?.kibana?.alert ? detailsEcsData?._id : null;
const ruleIndexRaw = useMemo(
() =>
find({ category: 'signal', field: 'signal.rule.index' }, detailsData)?.values ??
find({ category: 'kibana', field: 'kibana.alert.rule.parameters.index' }, detailsData)
?.values,
[detailsData]
);
const ruleIndex = useMemo(
(): string[] | undefined => (Array.isArray(ruleIndexRaw) ? ruleIndexRaw : undefined),
[ruleIndexRaw]
);
const ruleDataViewIdRaw = useMemo(
() =>
find({ category: 'signal', field: 'signal.rule.data_view_id' }, detailsData)?.values ??
find(
{ category: 'kibana', field: 'kibana.alert.rule.parameters.data_view_id' },
detailsData
)?.values,
[detailsData]
);
const ruleDataViewId = useMemo(
(): string | undefined =>
Array.isArray(ruleDataViewIdRaw) ? ruleDataViewIdRaw[0] : undefined,
[ruleDataViewIdRaw]
);
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 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<AddExceptionModalWrapperData>(
(acc, curr) => ({
...acc,
[curr.name]: getFieldValue({ category: curr.category, field: curr.field }, detailsData),
}),
{} as AddExceptionModalWrapperData
),
[detailsData]
);
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<AddExceptionModalWrapperData>(
(acc, curr) => ({
...acc,
[curr.name]: getFieldValue({ category: curr.category, field: curr.field }, detailsData),
}),
{} as AddExceptionModalWrapperData
),
[detailsData]
);
const refetchQuery = (newQueries: inputsModel.GlobalQuery[]) => {
newQueries.forEach((q) => q.refetch && (q.refetch as inputsModel.Refetch)());
};
const { refetch: refetchAll } = useRefetchByScope({ scopeId });
const refetchAll = useCallback(() => {
if (isActiveTimeline(scopeId)) {
refetchQuery([timelineQuery]);
} else {
refetchQuery(globalQuery);
}
}, [scopeId, timelineQuery, globalQuery]);
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 | string
>(null);
const closeOsqueryFlyout = useCallback(() => {
setOsqueryFlyoutOpenWithAgentId(null);
}, [setOsqueryFlyoutOpenWithAgentId]);
const closeOsqueryFlyout = useCallback(() => {
setOsqueryFlyoutOpenWithAgentId(null);
}, [setOsqueryFlyoutOpenWithAgentId]);
if (isReadOnly) {
return null;
}
if (isReadOnly) {
return null;
}
return (
<>
<EuiFlyoutFooter
className="side-panel-flyout-footer"
data-test-subj="side-panel-flyout-footer"
>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
{detailsEcsData && (
<TakeActionDropdown
detailsData={detailsData}
ecsData={detailsEcsData}
handleOnEventClosed={handleOnEventClosed}
isHostIsolationPanelOpen={isHostIsolationPanelOpen}
loadingEventDetails={loadingEventDetails}
onAddEventFilterClick={onAddEventFilterClick}
onAddExceptionTypeClick={onAddExceptionTypeClick}
onAddIsolationStatusClick={onAddIsolationStatusClick}
refetchFlyoutData={refetchFlyoutData}
refetch={refetchAll}
scopeId={scopeId}
onOsqueryClick={setOsqueryFlyoutOpenWithAgentId}
/>
)}
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
{/* This is still wrong to do render flyout/modal inside of the flyout
return (
<>
<EuiFlyoutFooter
className="side-panel-flyout-footer"
data-test-subj="side-panel-flyout-footer"
>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
{detailsEcsData && (
<TakeActionDropdown
detailsData={detailsData}
ecsData={detailsEcsData}
handleOnEventClosed={handleOnEventClosed}
isHostIsolationPanelOpen={isHostIsolationPanelOpen}
loadingEventDetails={loadingEventDetails}
onAddEventFilterClick={onAddEventFilterClick}
onAddExceptionTypeClick={onAddExceptionTypeClick}
onAddIsolationStatusClick={onAddIsolationStatusClick}
refetchFlyoutData={refetchFlyoutData}
refetch={refetchAll}
scopeId={scopeId}
onOsqueryClick={setOsqueryFlyoutOpenWithAgentId}
/>
)}
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
{/* 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 && (
<AddExceptionFlyoutWrapper
{...addExceptionModalWrapperData}
ruleIndices={ruleIndex}
ruleDataViewId={ruleDataViewId}
exceptionListType={exceptionFlyoutType}
onCancel={onAddExceptionCancel}
onConfirm={onAddExceptionConfirm}
/>
)}
{isAddEventFilterModalOpen && detailsEcsData != null && (
<EventFiltersFlyout data={detailsEcsData} onCancel={closeAddEventFilterModal} />
)}
{isOsqueryFlyoutOpenWithAgentId && detailsEcsData != null && (
<OsqueryFlyout
agentId={isOsqueryFlyoutOpenWithAgentId}
defaultValues={alertId ? { alertIds: [alertId] } : undefined}
onClose={closeOsqueryFlyout}
ecsData={detailsEcsData}
{openAddExceptionFlyout &&
addExceptionModalWrapperData.ruleId != null &&
addExceptionModalWrapperData.ruleRuleId != null &&
addExceptionModalWrapperData.eventId != null && (
<AddExceptionFlyoutWrapper
{...addExceptionModalWrapperData}
ruleIndices={ruleIndex}
ruleDataViewId={ruleDataViewId}
exceptionListType={exceptionFlyoutType}
onCancel={onAddExceptionCancel}
onConfirm={onAddExceptionConfirm}
/>
)}
</>
);
}
);
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 && (
<EventFiltersFlyout data={detailsEcsData} onCancel={closeAddEventFilterModal} />
)}
{isOsqueryFlyoutOpenWithAgentId && detailsEcsData != null && (
<OsqueryFlyout
agentId={isOsqueryFlyoutOpenWithAgentId}
defaultValues={alertId ? { alertIds: [alertId] } : undefined}
onClose={closeOsqueryFlyout}
ecsData={detailsEcsData}
/>
)}
</>
);
};
const connector = connect(makeMapStateToProps);
type PropsFromRedux = ConnectedProps<typeof connector>;
export const FlyoutFooter = connector(React.memo(FlyoutFooterComponent));
export const FlyoutFooter = React.memo(FlyoutFooterComponent);

View file

@ -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<void>;
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 (
<EuiFlyoutHeader hasBorder={isHostIsolationPanelOpen}>
@ -81,6 +94,9 @@ const FlyoutHeaderComponent = ({
ruleName={ruleName}
showAlertDetails={showAlertDetails}
timestamp={timestamp}
scopeId={scopeId}
refetchFlyoutData={refetchFlyoutData}
getFieldsData={getFieldsData}
/>
</EuiFlyoutHeader>
);

View file

@ -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 };
};

View file

@ -22,6 +22,7 @@ import {
DEFAULT_PREVIEW_INDEX,
ASSISTANT_FEATURE_ID,
} from '../../../../../common/constants';
import { useUpsellingMessage } from '../../../../common/hooks/use_upselling';
const ecsData: Ecs = {
_id: '1',
@ -69,6 +70,18 @@ jest.mock(
}
);
jest.mock('../../../../common/components/user_profiles/use_bulk_get_user_profiles', () => {
return {
useBulkGetUserProfiles: jest.fn().mockReturnValue({ isLoading: false, data: [] }),
};
});
jest.mock('../../../../common/components/user_profiles/use_suggest_users', () => {
return {
useSuggestUsers: jest.fn().mockReturnValue({ isLoading: false, data: [] }),
};
});
jest.mock('../../../../common/hooks/use_experimental_features', () => ({
useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true),
}));
@ -112,6 +125,7 @@ jest.mock('../../../../explore/containers/risk_score', () => {
}),
};
});
jest.mock('../../../../common/hooks/use_upselling');
const defaultProps = {
scopeId: TimelineId.test,
@ -167,6 +181,7 @@ describe('event details panel component', () => {
},
},
});
(useUpsellingMessage as jest.Mock).mockReturnValue('Go for Platinum!');
});
afterEach(() => {

View file

@ -13,6 +13,7 @@ import styled from 'styled-components';
import deepEqual from 'fast-deep-equal';
import type { EntityType } from '@kbn/timelines-plugin/common';
import { useGetFieldsData } from '../../../../common/hooks/use_get_fields_data';
import { useAssistantAvailability } from '../../../../assistant/use_assistant_availability';
import { getRawData } from '../../../../assistant/helpers';
import type { BrowserFields } from '../../../../common/containers/source';
@ -94,6 +95,7 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
skip: !expandedEvent.eventId,
}
);
const getFieldsData = useGetFieldsData(rawEventData?.fields);
const {
isolateAction,
@ -137,6 +139,9 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
showAlertDetails={showAlertDetails}
timestamp={timestamp}
promptContextId={promptContextId}
scopeId={scopeId}
refetchFlyoutData={refetchFlyoutData}
getFieldsData={getFieldsData}
/>
) : (
<ExpandableEventTitle
@ -148,6 +153,9 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
timestamp={timestamp}
handleOnEventClosed={handleOnEventClosed}
promptContextId={promptContextId}
scopeId={scopeId}
refetchFlyoutData={refetchFlyoutData}
getFieldsData={getFieldsData}
/>
),
[
@ -161,8 +169,11 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
ruleName,
showAlertDetails,
timestamp,
handleOnEventClosed,
promptContextId,
handleOnEventClosed,
scopeId,
refetchFlyoutData,
getFieldsData,
]
);

View file

@ -30,6 +30,18 @@ jest.mock('../../../common/containers/use_search_strategy', () => ({
useSearchStrategy: jest.fn(),
}));
jest.mock('../../../common/components/user_profiles/use_bulk_get_user_profiles', () => {
return {
useBulkGetUserProfiles: jest.fn().mockReturnValue({ isLoading: false, data: [] }),
};
});
jest.mock('../../../common/components/user_profiles/use_suggest_users', () => {
return {
useSuggestUsers: jest.fn().mockReturnValue({ isLoading: false, data: [] }),
};
});
jest.mock('../../../assistant/use_assistant_availability');
const mockUseLocation = jest.fn().mockReturnValue({ pathname: '/test', search: '?' });
jest.mock('react-router-dom', () => {

View file

@ -11,9 +11,14 @@ import type { Filter } from '@kbn/es-query';
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import type { ColumnHeaderOptions, RowRenderer } from '../../../../../../common/types';
import type { TimelineNonEcsData } from '../../../../../../common/search_strategy/timeline';
import type { RenderCellValueContext } from '../../../../../detections/configurations/security_solution_detections/fetch_page_context';
export interface ColumnRenderer {
isInstance: (columnName: string, data: TimelineNonEcsData[]) => boolean;
isInstance: (
columnName: string,
data: TimelineNonEcsData[],
context?: RenderCellValueContext
) => boolean;
renderColumn: ({
className,
columnName,
@ -28,6 +33,7 @@ export interface ColumnRenderer {
truncate,
values,
key,
context,
}: {
asPlainText?: boolean;
className?: string;
@ -44,5 +50,6 @@ export interface ColumnRenderer {
truncate?: boolean;
values: string[] | null | undefined;
key?: string;
context?: RenderCellValueContext;
}) => React.ReactNode;
}

View file

@ -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';

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import type { RenderCellValueContext } from '../../../../../detections/configurations/security_solution_detections/fetch_page_context';
import type { TimelineNonEcsData } from '../../../../../../common/search_strategy/timeline';
import type { ColumnRenderer } from './column_renderer';
@ -15,10 +16,11 @@ const unhandledColumnRenderer = (): never => {
export const getColumnRenderer = (
columnName: string,
columnRenderers: ColumnRenderer[],
data: TimelineNonEcsData[]
data: TimelineNonEcsData[],
context?: RenderCellValueContext
): ColumnRenderer => {
const renderer = columnRenderers.find((columnRenderer) =>
columnRenderer.isInstance(columnName, data)
columnRenderer.isInstance(columnName, data, context)
);
return renderer != null ? renderer : unhandledColumnRenderer();
};

View file

@ -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,

View file

@ -0,0 +1,58 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiLoadingSpinner } from '@elastic/eui';
import React from 'react';
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import { UsersAvatarsPanel } from '../../../../../common/components/user_profiles/users_avatars_panel';
import type { ColumnHeaderOptions, RowRenderer } from '../../../../../../common/types';
import type { ColumnRenderer } from './column_renderer';
import { profileUidColumns } from '../../../../../detections/configurations/security_solution_detections/fetch_page_context';
import type { RenderCellValueContext } from '../../../../../detections/configurations/security_solution_detections/fetch_page_context';
export const userProfileColumnRenderer: ColumnRenderer = {
isInstance: (columnName, _, context) => profileUidColumns.includes(columnName) && !!context,
renderColumn: ({
columnName,
ecsData,
eventId,
field,
isDetails,
isDraggable = true,
linkValues,
rowRenderers = [],
scopeId,
truncate,
values,
context,
}: {
columnName: string;
ecsData?: Ecs;
eventId: string;
field: ColumnHeaderOptions;
isDetails?: boolean;
isDraggable?: boolean;
linkValues?: string[] | null | undefined;
rowRenderers?: RowRenderer[];
scopeId: string;
truncate?: boolean;
values: string[] | undefined | null;
context?: RenderCellValueContext;
}) => {
// Show spinner if loading profiles or if there are no fetched profiles yet
// Do not show the loading spinner if context is not provided at all
if (context?.isLoading) {
return <EuiLoadingSpinner size="s" />;
}
const userProfiles =
values?.map((uid) => context?.profiles?.find((user) => uid === user.uid)) ?? [];
return <UsersAvatarsPanel userProfiles={userProfiles} maxVisibleAvatars={4} />;
},
};

View file

@ -76,7 +76,7 @@ describe('DefaultCellRenderer', () => {
</TestProviders>
);
expect(getColumnRenderer).toBeCalledWith(header.id, columnRenderers, data);
expect(getColumnRenderer).toBeCalledWith(header.id, columnRenderers, data, undefined);
});
test('if in tgrid expanded value, it invokes `renderColumn` with the expected arguments', () => {

View file

@ -33,6 +33,7 @@ export const DefaultCellRenderer: React.FC<CellValueElementProps> = ({
scopeId,
truncate,
asPlainText,
context,
}) => {
const asPlainTextDefault = useMemo(() => {
return (
@ -49,7 +50,7 @@ export const DefaultCellRenderer: React.FC<CellValueElementProps> = ({
: 'eui-displayInlineBlock eui-textTruncate';
return (
<StyledContent className={styledContentClassName} $isDetails={isDetails}>
{getColumnRenderer(header.id, columnRenderers, data).renderColumn({
{getColumnRenderer(header.id, columnRenderers, data, context).renderColumn({
asPlainText: asPlainText ?? asPlainTextDefault, // we want to render value with links as plain text but keep other formatters like badge. Except rule name for non preview tables
columnName: header.id,
ecsData,
@ -62,6 +63,7 @@ export const DefaultCellRenderer: React.FC<CellValueElementProps> = ({
scopeId,
truncate,
values,
context,
})}
</StyledContent>
);

View file

@ -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
*/

Some files were not shown because too many files have changed in this diff Show more