[Cases] Adding _find API for user actions (#148861)

This PR adds a new find API for retrieving a subset of the user actions
for a case.

Issue: https://github.com/elastic/kibana/issues/134344

```
GET /api/cases/<case_id>/user_actions/_find
Query Paramaters
{
  types?: Array of "assignees" | "comment" | "connector" | "description" | "pushed" | "tags" | "title" | "status" | "settings" | "severity" | "create_case" | "delete_case" | "action" | "alert" | "user" | "attachment"
  sortOrder?: "asc" | "desc"
  page?: number as a string
  perPage?: number as a string
}
```

<details><summary>Example request and response</summary>

Request
```
curl --location --request GET 'http://localhost:5601/api/cases/8df5fe00-96b1-11ed-9341-471c9630b5ec/user_actions/_find?types=create_case&sortOrder=asc' \
--header 'kbn-xsrf: hello' \
--header 'Authorization: Basic ZWxhc3RpYzpjaGFuZ2VtZQ==' \
--data-raw ''
```


Response
```
{
    "userActions": [
        {
            "created_at": "2023-01-17T21:54:45.527Z",
            "created_by": {
                "username": "elastic",
                "full_name": null,
                "email": null,
                "profile_uid": "u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0"
            },
            "owner": "cases",
            "action": "create",
            "payload": {
                "title": "Awesome case",
                "tags": [],
                "severity": "low",
                "description": "super",
                "assignees": [],
                "connector": {
                    "name": "none",
                    "type": ".none",
                    "fields": null,
                    "id": "none"
                },
                "settings": {
                    "syncAlerts": false
                },
                "owner": "cases",
                "status": "open"
            },
            "type": "create_case",
            "id": "8e121180-96b1-11ed-9341-471c9630b5ec",
            "case_id": "8df5fe00-96b1-11ed-9341-471c9630b5ec",
            "comment_id": null
        }
    ],
    "page": 1,
    "perPage": 20,
    "total": 1
}
```

</details>

## Notable Changes
- Created the new `_find` route
- Created a new `UserActionFinder` class and moved the find* methods
from the `index.ts` file into there as well as the new find logic
- Extracted the transform logic to its own file since its shared between
multiple files now
- Extracted the user action related integration test functions to the
`user_action.ts` utility file

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: lcawl <lcawley@elastic.co>
This commit is contained in:
Jonathan Buttner 2023-01-23 13:25:41 -05:00 committed by GitHub
parent a31328cedb
commit a78fece18b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
66 changed files with 3948 additions and 1021 deletions

View file

@ -23,6 +23,7 @@ Any modifications made to this file will be overwritten.
<li><a href="#deleteCase"><code><span class="http-method">delete</span> /s/{spaceId}/api/cases</code></a></li>
<li><a href="#deleteCaseComment"><code><span class="http-method">delete</span> /s/{spaceId}/api/cases/{caseId}/comments/{commentId}</code></a></li>
<li><a href="#deleteCaseComments"><code><span class="http-method">delete</span> /s/{spaceId}/api/cases/{caseId}/comments</code></a></li>
<li><a href="#findCaseActivity"><code><span class="http-method">get</span> /s/{spaceId}/api/cases/{caseId}/user_actions/_find</code></a></li>
<li><a href="#getAllCaseComments"><code><span class="http-method">get</span> /s/{spaceId}/api/cases/{caseId}/comments</code></a></li>
<li><a href="#getCase"><code><span class="http-method">get</span> /s/{spaceId}/api/cases/{caseId}</code></a></li>
<li><a href="#getCaseActivity"><code><span class="http-method">get</span> /s/{spaceId}/api/cases/{caseId}/user_actions</code></a></li>
@ -435,6 +436,102 @@ Any modifications made to this file will be overwritten.
<a href="#4xx_response">4xx_response</a>
</div> <!-- method -->
<hr/>
<div class="method"><a name="findCaseActivity"/>
<div class="method-path">
<a class="up" href="#__Methods">Up</a>
<pre class="get"><code class="huge"><span class="http-method">get</span> /s/{spaceId}/api/cases/{caseId}/user_actions/_find</code></pre></div>
<div class="method-summary">Finds user activity for a case. (<span class="nickname">findCaseActivity</span>)</div>
<div class="method-notes">You must have <code>read</code> privileges for the <strong>Cases</strong> feature in the <strong>Management</strong>, <strong>Observability</strong>, or <strong>Security</strong> section of the Kibana feature privileges, depending on the owner of the case you're seeking.</div>
<h3 class="field-label">Path parameters</h3>
<div class="field-items">
<div class="param">caseId (required)</div>
<div class="param-desc"><span class="param-type">Path Parameter</span> &mdash; The identifier for the case. To retrieve case IDs, use the find cases API. All non-ASCII characters must be URL encoded. default: null </div><div class="param">spaceId (required)</div>
<div class="param-desc"><span class="param-type">Path Parameter</span> &mdash; An identifier for the space. If <code>/s/</code> and the identifier are omitted from the path, the default space is used. default: null </div>
</div> <!-- field-items -->
<h3 class="field-label">Query parameters</h3>
<div class="field-items">
<div class="param">page (optional)</div>
<div class="param-desc"><span class="param-type">Query Parameter</span> &mdash; The page number to return. default: 1 </div><div class="param">perPage (optional)</div>
<div class="param-desc"><span class="param-type">Query Parameter</span> &mdash; The number of user actions to return per page. default: 20 </div><div class="param">sortOrder (optional)</div>
<div class="param-desc"><span class="param-type">Query Parameter</span> &mdash; Determines the sort order. default: asc </div><div class="param">types (optional)</div>
<div class="param-desc"><span class="param-type">Query Parameter</span> &mdash; Determines the types of user actions to return. default: null </div>
</div> <!-- field-items -->
<h3 class="field-label">Return type</h3>
<div class="return-type">
<a href="#findCaseActivity_200_response">findCaseActivity_200_response</a>
</div>
<!--Todo: process Response Object and its headers, schema, examples -->
<h3 class="field-label">Example data</h3>
<div class="example-data-content-type">Content-Type: application/json</div>
<pre class="example"><code>{
"userActions" : [ {
"owner" : "cases",
"case_id" : "22df07d0-03b1-11ed-920c-974bfa104448",
"action" : "create",
"created_at" : "2022-05-13T09:16:17.416Z",
"id" : "22fd3e30-03b1-11ed-920c-974bfa104448",
"comment_id" : "578608d0-03b1-11ed-920c-974bfa104448",
"type" : "create_case",
"created_by" : {
"full_name" : "full_name",
"profile_uid" : "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0",
"email" : "email",
"username" : "elastic"
},
"version" : "WzM1ODg4LDFd"
}, {
"owner" : "cases",
"case_id" : "22df07d0-03b1-11ed-920c-974bfa104448",
"action" : "create",
"created_at" : "2022-05-13T09:16:17.416Z",
"id" : "22fd3e30-03b1-11ed-920c-974bfa104448",
"comment_id" : "578608d0-03b1-11ed-920c-974bfa104448",
"type" : "create_case",
"created_by" : {
"full_name" : "full_name",
"profile_uid" : "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0",
"email" : "email",
"username" : "elastic"
},
"version" : "WzM1ODg4LDFd"
} ],
"total" : 1,
"perPage" : 6,
"page" : 0
}</code></pre>
<h3 class="field-label">Produces</h3>
This API call produces the following media types according to the <span class="header">Accept</span> request header;
the media type will be conveyed by the <span class="header">Content-Type</span> response header.
<ul>
<li><code>application/json</code></li>
</ul>
<h3 class="field-label">Responses</h3>
<h4 class="field-label">200</h4>
Indicates a successful call.
<a href="#findCaseActivity_200_response">findCaseActivity_200_response</a>
<h4 class="field-label">401</h4>
Authorization information is missing or invalid.
<a href="#4xx_response">4xx_response</a>
</div> <!-- method -->
<hr/>
<div class="method"><a name="getAllCaseComments"/>
<div class="method-path">
<a class="up" href="#__Methods">Up</a>
@ -1162,7 +1259,7 @@ Any modifications made to this file will be overwritten.
<div class="param-desc"><span class="param-type">Query Parameter</span> &mdash; The page number to return. default: 1 </div><div class="param">perPage (optional)</div>
<div class="param-desc"><span class="param-type">Query Parameter</span> &mdash; The number of rules to return per page. default: 20 </div><div class="param">reporters (optional)</div>
<div class="param-desc"><span class="param-type">Query Parameter</span> &mdash; The number of cases to return per page. default: 20 </div><div class="param">reporters (optional)</div>
<div class="param-desc"><span class="param-type">Query Parameter</span> &mdash; Filters the returned cases by the user name of the reporter. default: null </div><div class="param">search (optional)</div>
@ -1998,6 +2095,7 @@ Any modifications made to this file will be overwritten.
<li><a href="#create_case_request"><code>create_case_request</code> - Create case request</a></li>
<li><a href="#create_case_request_connector"><code>create_case_request_connector</code> - </a></li>
<li><a href="#external_service"><code>external_service</code> - </a></li>
<li><a href="#findCaseActivity_200_response"><code>findCaseActivity_200_response</code> - </a></li>
<li><a href="#getCaseComment_200_response"><code>getCaseComment_200_response</code> - </a></li>
<li><a href="#getCaseConfiguration_200_response_inner"><code>getCaseConfiguration_200_response_inner</code> - </a></li>
<li><a href="#getCaseConfiguration_200_response_inner_connector"><code>getCaseConfiguration_200_response_inner_connector</code> - </a></li>
@ -2043,6 +2141,7 @@ Any modifications made to this file will be overwritten.
<li><a href="#update_case_request"><code>update_case_request</code> - Update case request</a></li>
<li><a href="#update_case_request_cases_inner"><code>update_case_request_cases_inner</code> - </a></li>
<li><a href="#update_user_comment_request_properties"><code>update_user_comment_request_properties</code> - Update case comment request properties for user comments</a></li>
<li><a href="#user_actions_find_response_properties"><code>user_actions_find_response_properties</code> - </a></li>
<li><a href="#user_actions_response_properties"><code>user_actions_response_properties</code> - </a></li>
<li><a href="#user_actions_response_properties_created_by"><code>user_actions_response_properties_created_by</code> - </a></li>
<li><a href="#user_actions_response_properties_payload"><code>user_actions_response_properties_payload</code> - </a></li>
@ -2450,6 +2549,16 @@ Any modifications made to this file will be overwritten.
<div class="param">pushed_by (optional)</div><div class="param-desc"><span class="param-type"><a href="#getCaseConfiguration_200_response_inner_updated_by">getCaseConfiguration_200_response_inner_updated_by</a></span> </div>
</div> <!-- field-items -->
</div>
<div class="model">
<h3><a name="findCaseActivity_200_response"><code>findCaseActivity_200_response</code> - </a> <a class="up" href="#__Models">Up</a></h3>
<div class='model-description'></div>
<div class="field-items">
<div class="param">page (optional)</div><div class="param-desc"><span class="param-type"><a href="#integer">Integer</a></span> </div>
<div class="param">perPage (optional)</div><div class="param-desc"><span class="param-type"><a href="#integer">Integer</a></span> </div>
<div class="param">total (optional)</div><div class="param-desc"><span class="param-type"><a href="#integer">Integer</a></span> </div>
<div class="param">userActions (optional)</div><div class="param-desc"><span class="param-type"><a href="#user_actions_find_response_properties">array[user_actions_find_response_properties]</a></span> </div>
</div> <!-- field-items -->
</div>
<div class="model">
<h3><a name="getCaseComment_200_response"><code>getCaseComment_200_response</code> - </a> <a class="up" href="#__Models">Up</a></h3>
<div class='model-description'></div>
@ -2887,6 +2996,22 @@ Any modifications made to this file will be overwritten.
<div class="param">version </div><div class="param-desc"><span class="param-type"><a href="#string">String</a></span> The current comment version. To retrieve version values, use the get comments API. </div>
</div> <!-- field-items -->
</div>
<div class="model">
<h3><a name="user_actions_find_response_properties"><code>user_actions_find_response_properties</code> - </a> <a class="up" href="#__Models">Up</a></h3>
<div class='model-description'></div>
<div class="field-items">
<div class="param">action </div><div class="param-desc"><span class="param-type"><a href="#actions">actions</a></span> </div>
<div class="param">case_id </div><div class="param-desc"><span class="param-type"><a href="#string">String</a></span> </div>
<div class="param">comment_id </div><div class="param-desc"><span class="param-type"><a href="#string">String</a></span> </div>
<div class="param">created_at </div><div class="param-desc"><span class="param-type"><a href="#DateTime">Date</a></span> format: date-time</div>
<div class="param">created_by </div><div class="param-desc"><span class="param-type"><a href="#user_actions_response_properties_created_by">user_actions_response_properties_created_by</a></span> </div>
<div class="param">id </div><div class="param-desc"><span class="param-type"><a href="#string">String</a></span> </div>
<div class="param">owner </div><div class="param-desc"><span class="param-type"><a href="#owners">owners</a></span> </div>
<div class="param">payload </div><div class="param-desc"><span class="param-type"><a href="#user_actions_response_properties_payload">user_actions_response_properties_payload</a></span> </div>
<div class="param">version </div><div class="param-desc"><span class="param-type"><a href="#string">String</a></span> </div>
<div class="param">type </div><div class="param-desc"><span class="param-type"><a href="#action_types">action_types</a></span> </div>
</div> <!-- field-items -->
</div>
<div class="model">
<h3><a name="user_actions_response_properties"><code>user_actions_response_properties</code> - </a> <a class="up" href="#__Models">Up</a></h3>
<div class='model-description'></div>

View file

@ -400,10 +400,19 @@ Refer to the corresponding {es} logs for potential write errors.
| `success` | User has accessed the user activity of a case.
| `failure` | User is not authorized to access the user activity of a case.
.2+| `case_user_actions_find`
| `success` | User has accessed the user activity of a case as part of a search operation.
| `failure` | User is not authorized to access the user activity of a case.
.2+| `case_user_action_get_metrics`
| `success` | User has accessed metrics for the user activity of a case.
| `failure` | User is not authorized to access metrics for the user activity of a case.
.2+| `case_connectors_get`
| `success` | User has accessed the connectors of a case.
| `failure` | User is not authorized to access the connectors of a case.
3+a|
===== Category: web

View file

@ -8,6 +8,10 @@
import * as rt from 'io-ts';
import { UserRT } from '../../user';
/**
* These values are used in a number of places including to define the accepted values in the
* user_actions/_find api. These values should not be removed only new values can be added.
*/
export const ActionTypes = {
assignees: 'assignees',
comment: 'comment',
@ -43,6 +47,10 @@ export const UserActionCommonAttributesRt = rt.type({
action: ActionsRt,
});
/**
* This should only be used for the getAll route and it should be removed when the route is removed
* @deprecated use CaseUserActionInjectedIdsRt instead
*/
export const CaseUserActionSavedObjectIdsRt = rt.type({
action_id: rt.string,
case_id: rt.string,
@ -51,3 +59,16 @@ export const CaseUserActionSavedObjectIdsRt = rt.type({
export type UserActionWithAttributes<T> = T & rt.TypeOf<typeof UserActionCommonAttributesRt>;
export type UserActionWithResponse<T> = T & rt.TypeOf<typeof CaseUserActionSavedObjectIdsRt>;
/**
* This should be used for all user action types going forward it will be renamed to CaseUserActionSavedObjectIdsRt
* Once the UI is switched to using the new user actions _find API
*/
export const CaseUserActionInjectedIdsRt = rt.type({
comment_id: rt.union([rt.string, rt.null]),
});
/**
* Temporary type until CaseUserActionInjectedIdsRt replaces CaseUserActionSavedObjectIdsRt
*/
export type UserActionWithResponseInjection<T> = T & rt.TypeOf<typeof CaseUserActionInjectedIdsRt>;

View file

@ -5,23 +5,6 @@
* 2.0.
*/
import * as rt from 'io-ts';
import type { ActionsRt, ActionTypeValues } from './common';
import { UserActionCommonAttributesRt, CaseUserActionSavedObjectIdsRt } from './common';
import { CreateCaseUserActionRt } from './create_case';
import { DescriptionUserActionRt } from './description';
import { CommentUserActionRt } from './comment';
import { ConnectorUserActionRt } from './connector';
import { PushedUserActionRt } from './pushed';
import { TagsUserActionRt } from './tags';
import { TitleUserActionRt } from './title';
import { SettingsUserActionRt } from './settings';
import { StatusUserActionRt } from './status';
import { DeleteCaseUserActionRt } from './delete_case';
import { SeverityUserActionRt } from './severity';
import { AssigneesUserActionRt } from './assignees';
export * from './common';
export * from './comment';
export * from './connector';
@ -34,59 +17,5 @@ export * from './status';
export * from './tags';
export * from './title';
export * from './assignees';
const CommonUserActionsRt = rt.union([
DescriptionUserActionRt,
CommentUserActionRt,
TagsUserActionRt,
TitleUserActionRt,
SettingsUserActionRt,
StatusUserActionRt,
SeverityUserActionRt,
AssigneesUserActionRt,
]);
export const UserActionsRt = rt.union([
CommonUserActionsRt,
CreateCaseUserActionRt,
ConnectorUserActionRt,
PushedUserActionRt,
DeleteCaseUserActionRt,
]);
export const UserActionsWithoutConnectorIdRt = rt.union([
CommonUserActionsRt,
CreateCaseUserActionRt,
ConnectorUserActionRt,
PushedUserActionRt,
DeleteCaseUserActionRt,
]);
const CaseUserActionBasicRt = rt.intersection([UserActionsRt, UserActionCommonAttributesRt]);
const CaseUserActionBasicWithoutConnectorIdRt = rt.intersection([
UserActionsWithoutConnectorIdRt,
UserActionCommonAttributesRt,
]);
const CaseUserActionResponseRt = rt.intersection([
CaseUserActionBasicRt,
CaseUserActionSavedObjectIdsRt,
]);
export const CaseUserActionAttributesRt = CaseUserActionBasicRt;
export const CaseUserActionsResponseRt = rt.array(CaseUserActionResponseRt);
export type CaseUserActionAttributes = rt.TypeOf<typeof CaseUserActionAttributesRt>;
export type CaseUserActionAttributesWithoutConnectorId = rt.TypeOf<
typeof CaseUserActionAttributesRt
>;
export type CaseUserActionsResponse = rt.TypeOf<typeof CaseUserActionsResponseRt>;
export type CaseUserActionResponse = rt.TypeOf<typeof CaseUserActionResponseRt>;
export type UserAction = rt.TypeOf<typeof ActionsRt>;
export type UserActionTypes = ActionTypeValues;
export type CaseUserAction = rt.TypeOf<typeof CaseUserActionBasicRt>;
export type CaseUserActionWithoutConnectorId = rt.TypeOf<
typeof CaseUserActionBasicWithoutConnectorIdRt
>;
export * from './operations';
export * from './response';

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 * as rt from 'io-ts';
import { CaseUserActionsResponseWithoutActionIdRt } from '../response';
import { ActionTypes } from '../common';
import { NumberFromString } from '../../../saved_object';
const AdditionalFilterTypes = {
action: 'action',
alert: 'alert',
user: 'user',
attachment: 'attachment',
} as const;
export const FindTypes = {
...ActionTypes,
...AdditionalFilterTypes,
} as const;
const FindTypeFieldRt = rt.keyof(FindTypes);
export type FindTypeField = rt.TypeOf<typeof FindTypeFieldRt>;
export const UserActionFindRequestRt = rt.partial({
types: rt.array(FindTypeFieldRt),
sortOrder: rt.union([rt.literal('desc'), rt.literal('asc')]),
page: NumberFromString,
perPage: NumberFromString,
});
export type UserActionFindRequest = rt.TypeOf<typeof UserActionFindRequestRt>;
export const UserActionFindResponseRt = rt.type({
userActions: CaseUserActionsResponseWithoutActionIdRt,
page: rt.number,
perPage: rt.number,
total: rt.number,
});
export type UserActionFindResponse = rt.TypeOf<typeof UserActionFindResponseRt>;

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 './find';

View file

@ -0,0 +1,111 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as rt from 'io-ts';
import type { ActionsRt, ActionTypeValues } from './common';
import {
CaseUserActionInjectedIdsRt,
CaseUserActionSavedObjectIdsRt,
UserActionCommonAttributesRt,
} from './common';
import { CreateCaseUserActionRt } from './create_case';
import { DescriptionUserActionRt } from './description';
import { CommentUserActionRt } from './comment';
import { ConnectorUserActionRt } from './connector';
import { PushedUserActionRt } from './pushed';
import { TagsUserActionRt } from './tags';
import { TitleUserActionRt } from './title';
import { SettingsUserActionRt } from './settings';
import { StatusUserActionRt } from './status';
import { DeleteCaseUserActionRt } from './delete_case';
import { SeverityUserActionRt } from './severity';
import { AssigneesUserActionRt } from './assignees';
const CommonUserActionsRt = rt.union([
DescriptionUserActionRt,
CommentUserActionRt,
TagsUserActionRt,
TitleUserActionRt,
SettingsUserActionRt,
StatusUserActionRt,
SeverityUserActionRt,
AssigneesUserActionRt,
]);
export const UserActionsRt = rt.union([
CommonUserActionsRt,
CreateCaseUserActionRt,
ConnectorUserActionRt,
PushedUserActionRt,
DeleteCaseUserActionRt,
]);
export const UserActionsWithoutConnectorIdRt = rt.union([
CommonUserActionsRt,
CreateCaseUserActionRt,
ConnectorUserActionRt,
PushedUserActionRt,
DeleteCaseUserActionRt,
]);
const CaseUserActionBasicRt = rt.intersection([UserActionsRt, UserActionCommonAttributesRt]);
const CaseUserActionBasicWithoutConnectorIdRt = rt.intersection([
UserActionsWithoutConnectorIdRt,
UserActionCommonAttributesRt,
]);
const CaseUserActionResponseRt = rt.intersection([
CaseUserActionBasicRt,
CaseUserActionSavedObjectIdsRt,
]);
/**
* This includes the comment_id but not the action_id or case_id
*/
const CaseUserActionInjectedAttributesWithoutActionIdRt = rt.intersection([
CaseUserActionBasicRt,
CaseUserActionInjectedIdsRt,
]);
/**
* Rename to CaseUserActionResponseRt when the UI is switching to the new user action _find API
*/
const CaseUserActionResponseWithoutActionIdRt = rt.intersection([
CaseUserActionInjectedAttributesWithoutActionIdRt,
rt.type({
id: rt.string,
version: rt.string,
}),
]);
export const CaseUserActionAttributesRt = CaseUserActionBasicRt;
export const CaseUserActionsResponseRt = rt.array(CaseUserActionResponseRt);
export const CaseUserActionsResponseWithoutActionIdRt = rt.array(
CaseUserActionResponseWithoutActionIdRt
);
export type CaseUserActionAttributes = rt.TypeOf<typeof CaseUserActionAttributesRt>;
export type CaseUserActionAttributesWithoutConnectorId = rt.TypeOf<
typeof CaseUserActionAttributesRt
>;
export type CaseUserActionsResponse = rt.TypeOf<typeof CaseUserActionsResponseRt>;
export type CaseUserActionResponse = rt.TypeOf<typeof CaseUserActionResponseRt>;
export type CaseUserActionInjectedAttributesWithoutActionId = rt.TypeOf<
typeof CaseUserActionInjectedAttributesWithoutActionIdRt
>;
export type CaseUserActionsResponseWithoutActionId = rt.TypeOf<
typeof CaseUserActionsResponseWithoutActionIdRt
>;
export type UserAction = rt.TypeOf<typeof ActionsRt>;
export type UserActionTypes = ActionTypeValues;
export type CaseUserAction = rt.TypeOf<typeof CaseUserActionBasicRt>;
export type CaseUserActionWithoutConnectorId = rt.TypeOf<
typeof CaseUserActionBasicWithoutConnectorIdRt
>;

View file

@ -15,6 +15,7 @@ import {
CASE_CONFIGURE_DETAILS_URL,
CASE_ALERTS_URL,
CASE_COMMENT_DELETE_URL,
CASE_FIND_USER_ACTIONS_URL,
} from '../constants';
export const getCaseDetailsUrl = (id: string): string => {
@ -41,6 +42,10 @@ export const getCaseUserActionUrl = (id: string): string => {
return CASE_USER_ACTIONS_URL.replace('{case_id}', id);
};
export const getCaseFindUserActionsUrl = (id: string): string => {
return CASE_FIND_USER_ACTIONS_URL.replace('{case_id}', id);
};
export const getCasePushUrl = (caseId: string, connectorId: string): string => {
return CASE_PUSH_URL.replace('{case_id}', caseId).replace('{connector_id}', connectorId);
};

View file

@ -73,6 +73,7 @@ export const CASE_REPORTERS_URL = `${CASES_URL}/reporters` as const;
export const CASE_STATUS_URL = `${CASES_URL}/status` as const;
export const CASE_TAGS_URL = `${CASES_URL}/tags` as const;
export const CASE_USER_ACTIONS_URL = `${CASE_DETAILS_URL}/user_actions` as const;
export const CASE_FIND_USER_ACTIONS_URL = `${CASE_USER_ACTIONS_URL}/_find` as const;
export const CASE_ALERTS_URL = `${CASES_URL}/alerts/{alert_id}` as const;
export const CASE_DETAILS_ALERTS_URL = `${CASE_DETAILS_URL}/alerts` as const;

View file

@ -1,6 +1,6 @@
# OpenAPI (Experimental)
The current self-contained spec file is [as JSON](https://raw.githubusercontent.com/elastic/kibana/master/x-pack/plugins/cases/common/openapi/bundled.json) or [as YAML](https://raw.githubusercontent.com/elastic/kibana/master/x-pack/plugins/cases/common/openapi/bundled.yaml) and can be used for online tools like those found at https://openapi.tools/.
The current self-contained spec file is [as JSON](https://raw.githubusercontent.com/elastic/kibana/master/x-pack/plugins/cases/common/openapi/bundled.json) or [as YAML](https://raw.githubusercontent.com/elastic/kibana/master/x-pack/plugins/cases/common/openapi/bundled.yaml) and can be used for online tools like those found at <https://openapi.tools/>.
This spec is experimental and may be incomplete or change later.
A guide about the openApi specification can be found at [https://swagger.io/docs/specification/about/](https://swagger.io/docs/specification/about/).
@ -16,19 +16,19 @@ A guide about the openApi specification can be found at [https://swagger.io/docs
It is possible to validate the docs before bundling them with the following
command in the `x-pack/plugins/cases/docs/openapi/` folder:
```
```bash
npx swagger-cli validate entrypoint.yaml
```
Then you can generate the `bundled` files by running the following commands:
```
```bash
npx @redocly/cli bundle entrypoint.yaml --output bundled.yaml --ext yaml
npx @redocly/cli bundle entrypoint.yaml --output bundled.json --ext json
```
You can run additional linting with the following command:
After generating the json bundle ensure that it is also valid by running the following command:
```
```bash
npx @redocly/cli lint bundled.json
```

View file

@ -282,7 +282,7 @@
{
"name": "perPage",
"in": "query",
"description": "The number of rules to return per page.",
"description": "The number of cases to return per page.",
"schema": {
"type": "integer",
"default": 20
@ -2089,6 +2089,141 @@
"url": "https://localhost:5601"
}
]
},
"/s/{spaceId}/api/cases/{caseId}/user_actions/_find": {
"get": {
"summary": "Finds user activity for a case.",
"description": "You must have `read` privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the case you're seeking.\n",
"operationId": "findCaseActivity",
"tags": [
"cases"
],
"parameters": [
{
"$ref": "#/components/parameters/case_id"
},
{
"$ref": "#/components/parameters/space_id"
},
{
"name": "page",
"in": "query",
"description": "The page number to return.",
"schema": {
"type": "string",
"default": 1
},
"example": 1
},
{
"name": "perPage",
"in": "query",
"description": "The number of user actions to return per page.",
"schema": {
"type": "string",
"default": 20
},
"example": 20
},
{
"name": "sortOrder",
"in": "query",
"description": "Determines the sort order.",
"schema": {
"type": "string",
"enum": [
"asc",
"desc"
],
"default": "asc"
},
"example": "asc"
},
{
"name": "types",
"in": "query",
"description": "Determines the types of user actions to return.",
"schema": {
"type": "array",
"items": {
"type": "string",
"enum": [
"action",
"alert",
"assignees",
"attachment",
"comment",
"connector",
"create_case",
"description",
"pushed",
"settings",
"severity",
"status",
"tags",
"title",
"user"
]
}
},
"example": "create_case"
}
],
"responses": {
"200": {
"description": "Indicates a successful call.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"page": {
"type": "integer"
},
"perPage": {
"type": "integer"
},
"total": {
"type": "integer"
},
"userActions": {
"type": "array",
"items": {
"$ref": "#/components/schemas/user_actions_find_response_properties"
}
}
}
},
"examples": {
"findCaseActivityResponse": {
"$ref": "#/components/examples/find_case_activity_response"
}
}
}
}
},
"401": {
"description": "Authorization information is missing or invalid.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/4xx_response"
}
}
}
}
},
"servers": [
{
"url": "https://localhost:5601"
}
]
},
"servers": [
{
"url": "https://localhost:5601"
}
]
}
},
"components": {
@ -4126,6 +4261,136 @@
"$ref": "#/components/schemas/action_types"
}
}
},
"user_actions_find_response_properties": {
"type": "object",
"required": [
"action",
"comment_id",
"created_at",
"created_by",
"id",
"owner",
"payload",
"type",
"version"
],
"properties": {
"action": {
"$ref": "#/components/schemas/actions"
},
"comment_id": {
"type": "string",
"nullable": true,
"example": "578608d0-03b1-11ed-920c-974bfa104448"
},
"created_at": {
"type": "string",
"format": "date-time",
"example": "2022-05-13T09:16:17.416Z"
},
"created_by": {
"type": "object",
"properties": {
"email": {
"type": "string",
"example": null,
"nullable": true
},
"full_name": {
"type": "string",
"example": null,
"nullable": true
},
"username": {
"type": "string",
"example": "elastic",
"nullable": true
},
"profile_uid": {
"type": "string",
"example": "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0"
}
},
"required": [
"email",
"full_name",
"username"
]
},
"id": {
"type": "string",
"example": "22fd3e30-03b1-11ed-920c-974bfa104448"
},
"owner": {
"$ref": "#/components/schemas/owners"
},
"payload": {
"oneOf": [
{
"$ref": "#/components/schemas/payload_alert_comment"
},
{
"$ref": "#/components/schemas/payload_assignees"
},
{
"$ref": "#/components/schemas/payload_connector"
},
{
"$ref": "#/components/schemas/payload_create_case"
},
{
"$ref": "#/components/schemas/payload_delete"
},
{
"$ref": "#/components/schemas/payload_description"
},
{
"$ref": "#/components/schemas/payload_pushed"
},
{
"$ref": "#/components/schemas/payload_settings"
},
{
"$ref": "#/components/schemas/payload_severity"
},
{
"$ref": "#/components/schemas/payload_status"
},
{
"$ref": "#/components/schemas/payload_tags"
},
{
"$ref": "#/components/schemas/payload_title"
},
{
"$ref": "#/components/schemas/payload_user_comment"
}
]
},
"version": {
"type": "string",
"example": "WzM1ODg4LDFd"
},
"type": {
"type": "string",
"description": "The type of action.",
"enum": [
"assignees",
"create_case",
"comment",
"connector",
"description",
"pushed",
"tags",
"title",
"status",
"settings",
"severity"
],
"example": "create_case"
}
}
}
},
"examples": {
@ -4838,6 +5103,90 @@
"type": "delete_case"
}
]
},
"find_case_activity_response": {
"summary": "Retrieves all activity for a case",
"value": {
"page": 1,
"perPage": 20,
"total": 4,
"userActions": [
{
"id": "b4cd0770-07c9-11ed-a5fd-47154cb8767e",
"action": "create",
"comment_id": "578608d0-03b1-11ed-920c-974bfa104448",
"created_at": "2022-07-20T01:17:22.150Z",
"created_by": {
"username": "elastic",
"email": null,
"full_name": null
},
"owner": "cases",
"payload": {
"assignees": null,
"connector": {
"name": "none",
"type": ".none",
"fields": null,
"id": "none"
},
"description": "test",
"tags": [
"mine"
],
"title": "test-case",
"owner": "cases",
"settings": {
"syncAlerts": false
},
"severity": "low",
"status": "open",
"type": "create_case"
},
"version": "WzM1ODg4LDFd",
"type": "comment"
},
{
"id": "57af14a0-03b1-11ed-920c-974bfa104448",
"action": "create",
"comment_id": "578608d0-03b1-11ed-920c-974bfa104448",
"created_at": "2022-07-14T20:12:53.354Z",
"created_by": {
"username": "elastic",
"email": null,
"full_name": null
},
"owner": "cases",
"payload": {
"comment": "A new comment",
"owner": "cases",
"type": "user"
},
"version": "WzM1ODg4LDFa",
"type": "comment"
},
{
"id": "573c6980-6123-11ed-aa41-81a0a61fe447",
"action": "add",
"comment_id": null,
"created_at": "2022-07-20T01:10:28.238Z",
"created_by": {
"username": "elastic",
"email": null,
"full_name": null,
"profile_uid": "u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0"
},
"owner": "cases",
"payload": {
"assignees": {
"uid": "u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0"
}
},
"version": "WzM1ODg4LDFb",
"type": "assignees"
}
]
}
}
}
},

View file

@ -171,7 +171,7 @@ paths:
example: 1
- name: perPage
in: query
description: The number of rules to return per page.
description: The number of cases to return per page.
schema:
type: integer
default: 20
@ -1293,6 +1293,96 @@ paths:
- url: https://localhost:5601
servers:
- url: https://localhost:5601
/s/{spaceId}/api/cases/{caseId}/user_actions/_find:
get:
summary: Finds user activity for a case.
description: |
You must have `read` privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the case you're seeking.
operationId: findCaseActivity
tags:
- cases
parameters:
- $ref: '#/components/parameters/case_id'
- $ref: '#/components/parameters/space_id'
- name: page
in: query
description: The page number to return.
schema:
type: string
default: 1
example: 1
- name: perPage
in: query
description: The number of user actions to return per page.
schema:
type: string
default: 20
example: 20
- name: sortOrder
in: query
description: Determines the sort order.
schema:
type: string
enum:
- asc
- desc
default: asc
example: asc
- name: types
in: query
description: Determines the types of user actions to return.
schema:
type: array
items:
type: string
enum:
- action
- alert
- assignees
- attachment
- comment
- connector
- create_case
- description
- pushed
- settings
- severity
- status
- tags
- title
- user
example: create_case
responses:
'200':
description: Indicates a successful call.
content:
application/json:
schema:
type: object
properties:
page:
type: integer
perPage:
type: integer
total:
type: integer
userActions:
type: array
items:
$ref: '#/components/schemas/user_actions_find_response_properties'
examples:
findCaseActivityResponse:
$ref: '#/components/examples/find_case_activity_response'
'401':
description: Authorization information is missing or invalid.
content:
application/json:
schema:
$ref: '#/components/schemas/4xx_response'
servers:
- url: https://localhost:5601
servers:
- url: https://localhost:5601
components:
securitySchemes:
basicAuth:
@ -2758,6 +2848,90 @@ components:
- $ref: '#/components/schemas/payload_user_comment'
type:
$ref: '#/components/schemas/action_types'
user_actions_find_response_properties:
type: object
required:
- action
- comment_id
- created_at
- created_by
- id
- owner
- payload
- type
- version
properties:
action:
$ref: '#/components/schemas/actions'
comment_id:
type: string
nullable: true
example: 578608d0-03b1-11ed-920c-974bfa104448
created_at:
type: string
format: date-time
example: '2022-05-13T09:16:17.416Z'
created_by:
type: object
properties:
email:
type: string
example: null
nullable: true
full_name:
type: string
example: null
nullable: true
username:
type: string
example: elastic
nullable: true
profile_uid:
type: string
example: u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0
required:
- email
- full_name
- username
id:
type: string
example: 22fd3e30-03b1-11ed-920c-974bfa104448
owner:
$ref: '#/components/schemas/owners'
payload:
oneOf:
- $ref: '#/components/schemas/payload_alert_comment'
- $ref: '#/components/schemas/payload_assignees'
- $ref: '#/components/schemas/payload_connector'
- $ref: '#/components/schemas/payload_create_case'
- $ref: '#/components/schemas/payload_delete'
- $ref: '#/components/schemas/payload_description'
- $ref: '#/components/schemas/payload_pushed'
- $ref: '#/components/schemas/payload_settings'
- $ref: '#/components/schemas/payload_severity'
- $ref: '#/components/schemas/payload_status'
- $ref: '#/components/schemas/payload_tags'
- $ref: '#/components/schemas/payload_title'
- $ref: '#/components/schemas/payload_user_comment'
version:
type: string
example: WzM1ODg4LDFd
type:
type: string
description: The type of action.
enum:
- assignees
- create_case
- comment
- connector
- description
- pushed
- tags
- title
- status
- settings
- severity
example: create_case
examples:
create_case_request:
summary: Create a security case that uses a Jira connector.
@ -3315,6 +3489,71 @@ components:
owner: cases
payload: null
type: delete_case
find_case_activity_response:
summary: Retrieves all activity for a case
value:
page: 1
perPage: 20
total: 4
userActions:
- id: b4cd0770-07c9-11ed-a5fd-47154cb8767e
action: create
comment_id: 578608d0-03b1-11ed-920c-974bfa104448
created_at: '2022-07-20T01:17:22.150Z'
created_by:
username: elastic
email: null
full_name: null
owner: cases
payload:
assignees: null
connector:
name: none
type: .none
fields: null
id: none
description: test
tags:
- mine
title: test-case
owner: cases
settings:
syncAlerts: false
severity: low
status: open
type: create_case
version: WzM1ODg4LDFd
type: comment
- id: 57af14a0-03b1-11ed-920c-974bfa104448
action: create
comment_id: 578608d0-03b1-11ed-920c-974bfa104448
created_at: '2022-07-14T20:12:53.354Z'
created_by:
username: elastic
email: null
full_name: null
owner: cases
payload:
comment: A new comment
owner: cases
type: user
version: WzM1ODg4LDFa
type: comment
- id: 573c6980-6123-11ed-aa41-81a0a61fe447
action: add
comment_id: null
created_at: '2022-07-20T01:10:28.238Z'
created_by:
username: elastic
email: null
full_name: null
profile_uid: u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0
owner: cases
payload:
assignees:
uid: u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0
version: WzM1ODg4LDFb
type: assignees
security:
- basicAuth: []
- apiKeyAuth: []

View file

@ -0,0 +1,64 @@
summary: Retrieves all activity for a case
value:
page: 1
perPage: 20
total: 4
userActions:
- id: b4cd0770-07c9-11ed-a5fd-47154cb8767e
action: create
comment_id: 578608d0-03b1-11ed-920c-974bfa104448
created_at: 2022-07-20T01:17:22.150Z
created_by:
username: elastic
email: null
full_name: null
owner: cases
payload:
assignees:
connector:
name: none
type: .none
fields: null
id: none
description: test
tags:
- mine
title: test-case
owner: cases
settings:
syncAlerts: false
severity: low
status: open
type: create_case
version: WzM1ODg4LDFd
type: comment
- id: 57af14a0-03b1-11ed-920c-974bfa104448
action: create
comment_id: 578608d0-03b1-11ed-920c-974bfa104448
created_at: 2022-07-14T20:12:53.354Z
created_by:
username: elastic
email: null
full_name: null
owner: cases
payload:
comment: A new comment
owner: cases
type: user
version: WzM1ODg4LDFa
type: comment
- id: 573c6980-6123-11ed-aa41-81a0a61fe447
action: add
comment_id: null
created_at: 2022-07-20T01:10:28.238Z
created_by:
username: elastic
email: null
full_name: null
profile_uid: u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0
owner: cases
payload:
assignees:
uid: u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0
version: WzM1ODg4LDFb
type: assignees

View file

@ -0,0 +1,69 @@
type: object
required:
- action
- comment_id
- created_at
- created_by
- id
- owner
- payload
- type
- version
properties:
action:
$ref: 'actions.yaml'
comment_id:
type: string
nullable: true
example: 578608d0-03b1-11ed-920c-974bfa104448
created_at:
type: string
format: date-time
example: 2022-05-13T09:16:17.416Z
created_by:
type: object
properties:
$ref: 'user_properties.yaml'
required:
- email
- full_name
- username
id:
type: string
example: 22fd3e30-03b1-11ed-920c-974bfa104448
owner:
$ref: 'owners.yaml'
payload:
oneOf:
- $ref: 'payload_alert_comment.yaml'
- $ref: 'payload_assignees.yaml'
- $ref: 'payload_connector.yaml'
- $ref: 'payload_create_case.yaml'
- $ref: 'payload_delete.yaml'
- $ref: 'payload_description.yaml'
- $ref: 'payload_pushed.yaml'
- $ref: 'payload_settings.yaml'
- $ref: 'payload_severity.yaml'
- $ref: 'payload_status.yaml'
- $ref: 'payload_tags.yaml'
- $ref: 'payload_title.yaml'
- $ref: 'payload_user_comment.yaml'
version:
type: string
example: WzM1ODg4LDFd
type:
type: string
description: The type of action.
enum:
- assignees
- create_case
- comment
- connector
- description
- pushed
- tags
- title
- status
- settings
- severity
example: create_case

View file

@ -52,4 +52,4 @@ properties:
- $ref: 'payload_title.yaml'
- $ref: 'payload_user_comment.yaml'
type:
$ref: 'action_types.yaml'
$ref: 'action_types.yaml'

View file

@ -45,6 +45,8 @@ paths:
$ref: 'paths/s@{spaceid}@api@cases@{caseid}@connector@{connectorid}@_push.yaml'
'/s/{spaceId}/api/cases/{caseId}/user_actions':
$ref: 'paths/s@{spaceid}@api@cases@{caseid}@user_actions.yaml'
'/s/{spaceId}/api/cases/{caseId}/user_actions/_find':
$ref: 'paths/s@{spaceid}@api@cases@{caseid}@user_actions@_find.yaml'
components:
securitySchemes:
basicAuth:

View file

@ -54,7 +54,7 @@ get:
example: 1
- name: perPage
in: query
description: The number of rules to return per page.
description: The number of cases to return per page.
schema:
type: integer
default: 20
@ -83,7 +83,7 @@ get:
- type: array
items:
type: string
- $ref: '../components/parameters/severity.yaml'
- $ref: '../components/parameters/severity.yaml'
- name: sortField
in: query
description: Determines which field is used to sort the results.
@ -94,7 +94,7 @@ get:
- updatedAt
default: createdAt
example: updatedAt
- name: sortOrder
- name: sortOrder
in: query
description: Determines the sort order.
schema:
@ -104,17 +104,17 @@ get:
- desc
default: desc
example: asc
- name: status
- name: status
in: query
description: Filters the returned cases by state.
schema:
type: string
enum:
- closed
- in-progress
- open
type: string
enum:
- closed
- in-progress
- open
example: open
- name: tags
- name: tags
in: query
description: Filters the returned cases by tags.
schema:
@ -140,7 +140,7 @@ get:
'200':
description: Indicates a successful call.
content:
application/json:
application/json:
schema:
type: object
properties:
@ -172,4 +172,4 @@ get:
servers:
- url: https://localhost:5601
servers:
- url: https://localhost:5601
- url: https://localhost:5601

View file

@ -0,0 +1,91 @@
get:
summary: Finds user activity for a case.
description: >
You must have `read` privileges for the **Cases** feature in the
**Management**, **Observability**, or **Security** section of the Kibana
feature privileges, depending on the owner of the case you're seeking.
operationId: findCaseActivity
tags:
- cases
parameters:
- $ref: '../components/parameters/case_id.yaml'
- $ref: '../components/parameters/space_id.yaml'
- name: page
in: query
description: The page number to return.
schema:
type: string
default: 1
example: 1
- name: perPage
in: query
description: The number of user actions to return per page.
schema:
type: string
default: 20
example: 20
- name: sortOrder
in: query
description: Determines the sort order.
schema:
type: string
enum:
- asc
- desc
default: asc
example: asc
- name: types
in: query
description: Determines the types of user actions to return.
schema:
type: array
items:
type: string
enum:
- action
- alert
- assignees
- attachment
- comment
- connector
- create_case
- description
- pushed
- settings
- severity
- status
- tags
- title
- user
example: create_case
responses:
'200':
description: Indicates a successful call.
content:
application/json:
schema:
type: object
properties:
page:
type: integer
perPage:
type: integer
total:
type: integer
userActions:
type: array
items:
$ref: '../components/schemas/user_actions_find_response_properties.yaml'
examples:
findCaseActivityResponse:
$ref: '../components/examples/find_case_activity_response.yaml'
'401':
description: Authorization information is missing or invalid.
content:
application/json:
schema:
$ref: '../components/schemas/4xx_response.yaml'
servers:
- url: https://localhost:5601
servers:
- url: https://localhost:5601

View file

@ -840,6 +840,90 @@ Object {
}
`;
exports[`audit_logger log function event structure creates the correct audit event for operation: "findUserActions" with an error and entity 1`] = `
Object {
"error": Object {
"code": "Error",
"message": "an error",
},
"event": Object {
"action": "case_user_actions_find",
"category": Array [
"database",
],
"outcome": "failure",
"type": Array [
"access",
],
},
"kibana": Object {
"saved_object": Object {
"id": "1",
"type": "cases-user-actions",
},
},
"message": "Failed attempt to access cases-user-actions [id=1] as owner \\"awesome\\"",
}
`;
exports[`audit_logger log function event structure creates the correct audit event for operation: "findUserActions" with an error but no entity 1`] = `
Object {
"error": Object {
"code": "Error",
"message": "an error",
},
"event": Object {
"action": "case_user_actions_find",
"category": Array [
"database",
],
"outcome": "failure",
"type": Array [
"access",
],
},
"message": "Failed attempt to access a user actions as any owners",
}
`;
exports[`audit_logger log function event structure creates the correct audit event for operation: "findUserActions" without an error but with an entity 1`] = `
Object {
"event": Object {
"action": "case_user_actions_find",
"category": Array [
"database",
],
"outcome": "success",
"type": Array [
"access",
],
},
"kibana": Object {
"saved_object": Object {
"id": "5",
"type": "cases-user-actions",
},
},
"message": "User has accessed cases-user-actions [id=5] as owner \\"super\\"",
}
`;
exports[`audit_logger log function event structure creates the correct audit event for operation: "findUserActions" without an error or entity 1`] = `
Object {
"event": Object {
"action": "case_user_actions_find",
"category": Array [
"database",
],
"outcome": "success",
"type": Array [
"access",
],
},
"message": "User has accessed a user actions as any owners",
}
`;
exports[`audit_logger log function event structure creates the correct audit event for operation: "getAlertsAttachedToCase" with an error and entity 1`] = `
Object {
"error": Object {

View file

@ -310,6 +310,14 @@ export const Operations: Record<ReadOperations | WriteOperations, OperationDetai
docType: 'cases',
savedObjectType: CASE_SAVED_OBJECT,
},
[ReadOperations.FindUserActions]: {
ecsType: EVENT_TYPES.access,
name: ACCESS_USER_ACTION_OPERATION,
action: 'case_user_actions_find',
verbs: accessVerbs,
docType: 'user actions',
savedObjectType: CASE_USER_ACTION_SAVED_OBJECT,
},
[ReadOperations.GetUserActions]: {
ecsType: EVENT_TYPES.access,
name: ACCESS_USER_ACTION_OPERATION,

View file

@ -37,6 +37,7 @@ export enum ReadOperations {
GetTags = 'getTags',
GetReporters = 'getReporters',
FindConfigurations = 'findConfigurations',
FindUserActions = 'findUserActions',
GetUserActions = 'getUserActions',
GetConnectors = 'getConnectors',
GetAlertsAttachedToCase = 'getAlertsAttachedToCase',

View file

@ -34,7 +34,7 @@ export class CasesClient {
this._casesClientInternal = createCasesClientInternal(args);
this._cases = createCasesSubClient(args, this, this._casesClientInternal);
this._attachments = createAttachmentsSubClient(args, this, this._casesClientInternal);
this._userActions = createUserActionsSubClient(args);
this._userActions = createUserActionsSubClient(args, this);
this._configure = createConfigurationSubClient(args, this._casesClientInternal);
this._metrics = createMetricsSubClient(args, this);
}

View file

@ -202,7 +202,7 @@ function createMockClientArgs() {
const logger = loggingSystemMock.createLogger();
const userActionService = createUserActionServiceMock();
userActionService.findStatusChanges.mockImplementation(async () => {
userActionService.finder.findStatusChanges.mockImplementation(async () => {
return [
createStatusChangeSavedObject(CaseStatuses['in-progress'], new Date('2021-11-23T20:00:43Z')),
];

View file

@ -6,9 +6,10 @@
*/
import type { SavedObject } from '@kbn/core/server';
import type { CaseUserActionResponse } from '../../../common/api';
import type { CaseUserActionInjectedAttributesWithoutActionId } from '../../../common/api';
import { CaseStatuses } from '../../../common/api';
import { getStatusInfo } from './lifespan';
import { createStatusChangeSavedObject } from './test_utils/lifespan';
describe('lifespan', () => {
describe('getStatusInfo', () => {
@ -119,7 +120,7 @@ describe('lifespan', () => {
[
{
attributes: { payload: { hello: 1, status: CaseStatuses.closed }, type: 'status' },
} as unknown as SavedObject<CaseUserActionResponse>,
} as unknown as SavedObject<CaseUserActionInjectedAttributesWithoutActionId>,
],
new Date(0)
);
@ -132,7 +133,7 @@ describe('lifespan', () => {
[
{
attributes: { payload: { status: CaseStatuses.closed }, type: 'awesome' },
} as unknown as SavedObject<CaseUserActionResponse>,
} as unknown as SavedObject<CaseUserActionInjectedAttributesWithoutActionId>,
],
new Date(0)
);
@ -148,7 +149,7 @@ describe('lifespan', () => {
payload: { status: CaseStatuses.closed, created_at: 'blah' },
type: 'status',
},
} as unknown as SavedObject<CaseUserActionResponse>,
} as unknown as SavedObject<CaseUserActionInjectedAttributesWithoutActionId>,
],
new Date(0)
);
@ -169,31 +170,3 @@ describe('lifespan', () => {
});
});
});
function createStatusChangeSavedObject(
status: CaseStatuses,
createdAt: Date
): SavedObject<CaseUserActionResponse> {
return {
references: [],
id: '',
type: '',
attributes: {
created_at: createdAt.toISOString(),
created_by: {
username: 'j@j.com',
email: null,
full_name: null,
},
owner: 'securitySolution',
action: 'update',
payload: {
status,
},
type: 'status',
action_id: '',
case_id: '',
comment_id: null,
},
};
}

View file

@ -7,7 +7,7 @@
import type { SavedObject } from '@kbn/core/server';
import type {
CaseUserActionResponse,
CaseUserActionInjectedAttributesWithoutActionId,
SingleCaseMetricsResponse,
StatusInfo,
StatusUserAction,
@ -47,7 +47,7 @@ export class Lifespan extends SingleCaseBaseHandler {
Operations.getUserActionMetrics
);
const statusUserActions = await userActionService.findStatusChanges({
const statusUserActions = await userActionService.finder.findStatusChanges({
caseId: this.caseId,
filter: authorizationFilter,
});
@ -83,7 +83,7 @@ interface StatusCalculations {
}
export function getStatusInfo(
statusUserActions: Array<SavedObject<CaseUserActionResponse>>,
statusUserActions: Array<SavedObject<CaseUserActionInjectedAttributesWithoutActionId>>,
caseOpenTimestamp: Date
): StatusInfo {
const accStatusInfo = statusUserActions.reduce<StatusCalculations>(
@ -138,7 +138,7 @@ export function getStatusInfo(
}
function isValidStatusChangeUserAction(
attributes: CaseUserActionResponse,
attributes: CaseUserActionInjectedAttributesWithoutActionId,
newStatusChangeTimestamp: Date
): attributes is UserActionWithResponse<StatusUserAction> {
return StatusUserActionRt.is(attributes) && isDateValid(newStatusChangeTimestamp);

View file

@ -6,12 +6,15 @@
*/
import type { SavedObject } from '@kbn/core/server';
import type { CaseStatuses, CaseUserActionResponse } from '../../../../common/api';
import type {
CaseStatuses,
CaseUserActionInjectedAttributesWithoutActionId,
} from '../../../../common/api';
export function createStatusChangeSavedObject(
status: CaseStatuses,
createdAt: Date
): SavedObject<CaseUserActionResponse> {
): SavedObject<CaseUserActionInjectedAttributesWithoutActionId> {
return {
references: [],
id: '',
@ -29,8 +32,6 @@ export function createStatusChangeSavedObject(
status,
},
type: 'status',
action_id: '',
case_id: '',
comment_id: null,
},
};

View file

@ -84,6 +84,7 @@ const createUserActionsSubClientMock = (): UserActionsSubClientMock => {
return {
getAll: jest.fn(),
getConnectors: jest.fn(),
find: jest.fn(),
};
};

View file

@ -5,34 +5,44 @@
* 2.0.
*/
import type { GetCaseConnectorsResponse } from '../../../common/api';
import type { ICaseUserActionsResponse } from '../typedoc_interfaces';
import type {
GetCaseConnectorsResponse,
CaseUserActionsResponse,
UserActionFindResponse,
} from '../../../common/api';
import type { CasesClientArgs } from '../types';
import { get } from './get';
import { getConnectors } from './connectors';
import type { GetConnectorsRequest, UserActionGet } from './types';
import type { GetConnectorsRequest, UserActionFind, UserActionGet } from './types';
import { find } from './find';
import type { CasesClient } from '../client';
/**
* API for interacting the actions performed by a user when interacting with the cases entities.
*/
export interface UserActionsSubClient {
find(params: UserActionFind): Promise<UserActionFindResponse>;
/**
* Retrieves all user actions for a particular case.
*/
getAll(clientArgs: UserActionGet): Promise<ICaseUserActionsResponse>;
getAll(params: UserActionGet): Promise<CaseUserActionsResponse>;
/**
* Retrieves all the connectors used within a given case
*/
getConnectors(clientArgs: GetConnectorsRequest): Promise<GetCaseConnectorsResponse>;
getConnectors(params: GetConnectorsRequest): Promise<GetCaseConnectorsResponse>;
}
/**
* Creates an API object for interacting with the user action entities
*/
export const createUserActionsSubClient = (clientArgs: CasesClientArgs): UserActionsSubClient => {
export const createUserActionsSubClient = (
clientArgs: CasesClientArgs,
casesClient: CasesClient
): UserActionsSubClient => {
const attachmentSubClient: UserActionsSubClient = {
getAll: (params: UserActionGet) => get(params, clientArgs),
getConnectors: (params: GetConnectorsRequest) => getConnectors(params, clientArgs),
find: (params) => find(params, casesClient, clientArgs),
getAll: (params) => get(params, clientArgs),
getConnectors: (params) => getConnectors(params, clientArgs),
};
return Object.freeze(attachmentSubClient);

View file

@ -7,13 +7,13 @@
import { isEqual } from 'lodash';
import type { SavedObject } from '@kbn/core/server';
import type { PublicMethodsOf } from '@kbn/utility-types';
import type { ActionResult, ActionsClient } from '@kbn/actions-plugin/server';
import type { SavedObject } from '@kbn/core-saved-objects-common/src/server_types';
import type {
CaseUserActionResponse,
GetCaseConnectorsResponse,
CaseConnector,
CaseUserActionInjectedAttributesWithoutActionId,
} from '../../../common/api';
import { GetCaseConnectorsResponseRt } from '../../../common/api';
import { isConnectorUserAction, isCreateCaseUserAction } from '../../../common/utils/user_actions';
@ -68,7 +68,7 @@ const checkConnectorsAuthorization = async ({
authorization,
}: {
connectors: CaseConnectorActivity[];
latestUserAction?: SavedObject<CaseUserActionResponse>;
latestUserAction?: SavedObject<CaseUserActionInjectedAttributesWithoutActionId>;
authorization: PublicMethodsOf<Authorization>;
}) => {
const entities: OwnerEntity[] = latestUserAction
@ -109,7 +109,7 @@ const getConnectorsInfo = async ({
}: {
caseId: string;
connectors: CaseConnectorActivity[];
latestUserAction?: SavedObject<CaseUserActionResponse>;
latestUserAction?: SavedObject<CaseUserActionInjectedAttributesWithoutActionId>;
actionsClient: PublicMethodsOf<ActionsClient>;
userActionService: CaseUserActionService;
}): Promise<GetCaseConnectorsResponse> => {
@ -180,7 +180,7 @@ const isDateValid = (date: Date): boolean => {
};
const getConnectorInfoFromSavedObject = (
savedObject: SavedObject<CaseUserActionResponse> | undefined
savedObject: SavedObject<CaseUserActionInjectedAttributesWithoutActionId> | undefined
): CaseConnector | undefined => {
if (
savedObject != null &&
@ -200,7 +200,7 @@ const createConnectorInfoResult = ({
actionConnectors: ActionResult[];
connectors: CaseConnectorActivity[];
pushInfo: Map<string, EnrichedPushInfo>;
latestUserAction?: SavedObject<CaseUserActionResponse>;
latestUserAction?: SavedObject<CaseUserActionInjectedAttributesWithoutActionId>;
}) => {
const results: GetCaseConnectorsResponse = {};

View file

@ -0,0 +1,79 @@
/*
* 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 Boom from '@hapi/boom';
import { pipe } from 'fp-ts/lib/pipeable';
import { fold } from 'fp-ts/lib/Either';
import { identity } from 'fp-ts/lib/function';
import type { UserActionFindResponse } from '../../../common/api';
import {
UserActionFindRequestRt,
throwErrors,
excess,
UserActionFindResponseRt,
} from '../../../common/api';
import type { CasesClientArgs } from '../types';
import type { UserActionFind } from './types';
import { Operations } from '../../authorization';
import { formatSavedObjects } from './utils';
import { createCaseError } from '../../common/error';
import { asArray } from '../../common/utils';
import type { CasesClient } from '../client';
export const find = async (
{ caseId, params }: UserActionFind,
casesClient: CasesClient,
clientArgs: CasesClientArgs
): Promise<UserActionFindResponse> => {
const {
services: { userActionService },
logger,
authorization,
} = clientArgs;
try {
// supertest and query-string encode a single entry in an array as just a string so make sure we have an array
const types = asArray(params.types);
const queryParams = pipe(
excess(UserActionFindRequestRt).decode({ ...params, types }),
fold(throwErrors(Boom.badRequest), identity)
);
const [authorizationFilterRes] = await Promise.all([
authorization.getAuthorizationFilter(Operations.findUserActions),
// ensure that we have authorization for reading the case
casesClient.cases.resolve({ id: caseId, includeComments: false }),
]);
const { filter: authorizationFilter, ensureSavedObjectsAreAuthorized } = authorizationFilterRes;
const userActions = await userActionService.finder.find({
caseId,
...queryParams,
filter: authorizationFilter,
});
ensureSavedObjectsAreAuthorized(
userActions.saved_objects.map((so) => ({ owner: so.attributes.owner, id: so.id }))
);
return UserActionFindResponseRt.encode({
userActions: formatSavedObjects(userActions),
page: userActions.page,
perPage: userActions.per_page,
total: userActions.total,
});
} catch (error) {
throw createCaseError({
message: `Failed to find user actions for case id: ${caseId}: ${error}`,
error,
logger,
});
}
};

View file

@ -5,13 +5,13 @@
* 2.0.
*/
import type { SavedObjectsFindResponse } from '@kbn/core/server';
import type { CaseUserActionsResponse, CaseUserActionResponse } from '../../../common/api';
import type { CaseUserActionsResponse } from '../../../common/api';
import { CaseUserActionsResponseRt } from '../../../common/api';
import { createCaseError } from '../../common/error';
import type { CasesClientArgs } from '..';
import { Operations } from '../../authorization';
import type { UserActionGet } from './types';
import { extractAttributes } from './utils';
export const get = async (
{ caseId }: UserActionGet,
@ -45,9 +45,3 @@ export const get = async (
});
}
};
function extractAttributes(
userActions: SavedObjectsFindResponse<CaseUserActionResponse>
): CaseUserActionsResponse {
return userActions.saved_objects.map((so) => so.attributes);
}

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import type { UserActionFindRequest } from '../../../common/api';
/**
* Parameters for retrieving user actions for a particular case
*/
@ -16,3 +18,8 @@ export interface UserActionGet {
}
export type GetConnectorsRequest = UserActionGet;
export interface UserActionFind {
params: UserActionFindRequest;
caseId: string;
}

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { SavedObjectsFindResponse } from '@kbn/core-saved-objects-api-server';
import type {
CaseUserActionResponse,
CaseUserActionInjectedAttributesWithoutActionId,
CaseUserActionsResponse,
CaseUserActionsResponseWithoutActionId,
} from '../../../common/api';
export const extractAttributes = (
userActions: SavedObjectsFindResponse<CaseUserActionResponse>
): CaseUserActionsResponse => {
return userActions.saved_objects.map((so) => so.attributes);
};
export const formatSavedObjects = (
response: SavedObjectsFindResponse<CaseUserActionInjectedAttributesWithoutActionId>
): CaseUserActionsResponseWithoutActionId =>
response.saved_objects.map((so) => ({ id: so.id, version: so.version ?? '', ...so.attributes }));

View file

@ -180,10 +180,17 @@ export const addSeverityFilter = ({
return filters.length > 1 ? nodeBuilder.and(filters) : filters[0];
};
export const NodeBuilderOperators = {
and: 'and',
or: 'or',
} as const;
type NodeBuilderOperatorsType = keyof typeof NodeBuilderOperators;
interface FilterField {
filters?: string | string[];
field: string;
operator: 'and' | 'or';
operator: NodeBuilderOperatorsType;
type?: string;
}
@ -273,14 +280,17 @@ export const combineAuthorizedAndOwnerFilter = (
/**
* Combines Kuery nodes and accepts an array with a mixture of undefined and KueryNodes. This will filter out the undefined
* filters and return a KueryNode with the filters and'd together.
* filters and return a KueryNode with the filters combined using the specified operator which defaults to and if not defined.
*/
export function combineFilters(nodes: Array<KueryNode | undefined>): KueryNode | undefined {
export function combineFilters(
nodes: Array<KueryNode | undefined>,
operator: NodeBuilderOperatorsType = NodeBuilderOperators.and
): KueryNode | undefined {
const filters = nodes.filter((node): node is KueryNode => node !== undefined);
if (filters.length <= 0) {
return;
}
return nodeBuilder.and(filters);
return nodeBuilder[operator](filters);
}
/**

View file

@ -31,6 +31,7 @@ import { postCaseConfigureRoute } from './configure/post_configure';
import { getAllAlertsAttachedToCaseRoute } from './comments/get_alerts';
import { getCaseMetricRoute } from './metrics/get_case_metrics';
import { getCasesMetricRoute } from './metrics/get_cases_metrics';
import { findUserActionsRoute } from './user_actions/find_user_actions';
export const getExternalRoutes = () =>
[
@ -41,6 +42,7 @@ export const getExternalRoutes = () =>
patchCaseRoute,
postCaseRoute,
pushCaseRoute,
findUserActionsRoute,
getUserActionsRoute,
getStatusRoute,
getCasesByAlertIdRoute,

View file

@ -0,0 +1,40 @@
/*
* 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 { schema } from '@kbn/config-schema';
import { CASE_FIND_USER_ACTIONS_URL } from '../../../../common/constants';
import { createCaseError } from '../../../common/error';
import { createCasesRoute } from '../create_cases_route';
import type { UserActionFindRequest } from '../../../../common/api';
export const findUserActionsRoute = createCasesRoute({
method: 'get',
path: CASE_FIND_USER_ACTIONS_URL,
params: {
params: schema.object({
case_id: schema.string(),
}),
},
handler: async ({ context, request, response }) => {
try {
const caseContext = await context.cases;
const casesClient = await caseContext.getCasesClient();
const caseId = request.params.case_id;
const options = request.query as UserActionFindRequest;
return response.ok({
body: await casesClient.userActions.find({ caseId, params: options }),
});
} catch (error) {
throw createCaseError({
message: `Failed to find user actions in route for case id: ${request.params.case_id}: ${error}`,
error,
});
}
},
});

View file

@ -17,12 +17,21 @@ import type {
import type { LicensingService } from './licensing';
import type { EmailNotificationService } from './notifications/email_notification_service';
import type { UserActionPersister } from './user_actions/operations/create';
import type { UserActionFinder } from './user_actions/operations/find';
interface UserActionServiceOperations {
creator: CaseUserActionPersisterServiceMock;
finder: CaseUserActionFinderServiceMock;
}
export type CaseServiceMock = jest.Mocked<CasesService>;
export type CaseConfigureServiceMock = jest.Mocked<CaseConfigureService>;
export type ConnectorMappingsServiceMock = jest.Mocked<ConnectorMappingsService>;
export type CaseUserActionServiceMock = jest.Mocked<CaseUserActionService>;
export type CaseUserActionServiceMock = jest.Mocked<
CaseUserActionService & UserActionServiceOperations
>;
export type CaseUserActionPersisterServiceMock = jest.Mocked<UserActionPersister>;
export type CaseUserActionFinderServiceMock = jest.Mocked<UserActionFinder>;
export type AlertServiceMock = jest.Mocked<AlertService>;
export type AttachmentServiceMock = jest.Mocked<AttachmentService>;
export type LicensingServiceMock = jest.Mocked<LicensingService>;
@ -87,18 +96,25 @@ const createUserActionPersisterServiceMock = (): CaseUserActionPersisterServiceM
return service as unknown as CaseUserActionPersisterServiceMock;
};
type FakeUserActionService = PublicMethodsOf<CaseUserActionService> & {
creator: CaseUserActionPersisterServiceMock;
const createUserActionFinderServiceMock = (): CaseUserActionFinderServiceMock => {
const service: PublicMethodsOf<UserActionFinder> = {
find: jest.fn(),
findStatusChanges: jest.fn(),
};
return service as unknown as CaseUserActionFinderServiceMock;
};
type FakeUserActionService = PublicMethodsOf<CaseUserActionService> & UserActionServiceOperations;
export const createUserActionServiceMock = (): CaseUserActionServiceMock => {
const service: FakeUserActionService = {
creator: createUserActionPersisterServiceMock(),
finder: createUserActionFinderServiceMock(),
getConnectorFieldsBeforeLatestPush: jest.fn(),
getMostRecentUserAction: jest.fn(),
getCaseConnectorInformation: jest.fn(),
getAll: jest.fn(),
findStatusChanges: jest.fn(),
getUniqueConnectors: jest.fn(),
getUserActionIdsForCases: jest.fn(),
};

View file

@ -0,0 +1,333 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`transform legacyTransformFindResponseToExternalModel external references populates the external references attributes 1`] = `
Object {
"page": 1,
"per_page": 1,
"saved_objects": Array [
Object {
"attributes": Object {
"action": "create",
"action_id": "100",
"case_id": "1",
"comment_id": "external-reference-test-id",
"created_at": "abc",
"created_by": Object {
"email": "a",
"full_name": "abc",
"username": "b",
},
"owner": "securitySolution",
"payload": Object {
"comment": Object {
"externalReferenceAttachmentTypeId": ".test",
"externalReferenceId": "my-id",
"externalReferenceMetadata": null,
"externalReferenceStorage": Object {
"soType": "test-so",
"type": "savedObject",
},
"owner": "securitySolution",
"type": "externalReference",
},
},
"type": "comment",
},
"id": "100",
"references": Array [
Object {
"id": "my-id",
"name": "externalReferenceId",
"type": "test-so",
},
Object {
"id": "1",
"name": "associated-cases",
"type": "cases",
},
Object {
"id": "external-reference-test-id",
"name": "associated-cases-comments",
"type": "cases-comments",
},
],
"score": 0,
"type": "cases-user-actions",
},
],
"total": 1,
}
`;
exports[`transform legacyTransformFindResponseToExternalModel persistable state attachments populates the persistable state 1`] = `
Object {
"page": 1,
"per_page": 1,
"saved_objects": Array [
Object {
"attributes": Object {
"action": "create",
"action_id": "100",
"case_id": "1",
"comment_id": "persistable-state-test-id",
"created_at": "abc",
"created_by": Object {
"email": "a",
"full_name": "abc",
"username": "b",
},
"owner": "securitySolution",
"payload": Object {
"comment": Object {
"owner": "securitySolutionFixture",
"persistableStateAttachmentState": Object {
"foo": "foo",
"injectedId": "testRef",
},
"persistableStateAttachmentTypeId": ".test",
"type": "persistableState",
},
},
"type": "comment",
},
"id": "100",
"references": Array [
Object {
"id": "testRef",
"name": "myTestReference",
"type": "test-so",
},
Object {
"id": "1",
"name": "associated-cases",
"type": "cases",
},
Object {
"id": "persistable-state-test-id",
"name": "associated-cases-comments",
"type": "cases-comments",
},
],
"score": 0,
"type": "cases-user-actions",
},
],
"total": 1,
}
`;
exports[`transform legacyTransformFindResponseToExternalModel preserves the saved object fields and attributes when inject the ids 1`] = `
Object {
"page": 1,
"per_page": 1,
"saved_objects": Array [
Object {
"attributes": Object {
"action": "create",
"action_id": "100",
"case_id": "1",
"comment_id": null,
"created_at": "abc",
"created_by": Object {
"email": "a",
"full_name": "abc",
"username": "b",
},
"owner": "securitySolution",
"payload": Object {
"connector": Object {
"fields": Object {
"issueType": "bug",
"parent": "2",
"priority": "high",
},
"id": "1",
"name": ".jira",
"type": ".jira",
},
},
"type": "connector",
},
"id": "100",
"references": Array [
Object {
"id": "1",
"name": "associated-cases",
"type": "cases",
},
Object {
"id": "1",
"name": "connectorId",
"type": "action",
},
],
"score": 0,
"type": "cases-user-actions",
},
],
"total": 1,
}
`;
exports[`transform transformFindResponseToExternalModel external references populates the external references attributes 1`] = `
Object {
"page": 1,
"per_page": 1,
"saved_objects": Array [
Object {
"attributes": Object {
"action": "create",
"comment_id": "external-reference-test-id",
"created_at": "abc",
"created_by": Object {
"email": "a",
"full_name": "abc",
"username": "b",
},
"owner": "securitySolution",
"payload": Object {
"comment": Object {
"externalReferenceAttachmentTypeId": ".test",
"externalReferenceId": "my-id",
"externalReferenceMetadata": null,
"externalReferenceStorage": Object {
"soType": "test-so",
"type": "savedObject",
},
"owner": "securitySolution",
"type": "externalReference",
},
},
"type": "comment",
},
"id": "100",
"references": Array [
Object {
"id": "my-id",
"name": "externalReferenceId",
"type": "test-so",
},
Object {
"id": "1",
"name": "associated-cases",
"type": "cases",
},
Object {
"id": "external-reference-test-id",
"name": "associated-cases-comments",
"type": "cases-comments",
},
],
"score": 0,
"type": "cases-user-actions",
},
],
"total": 1,
}
`;
exports[`transform transformFindResponseToExternalModel persistable state attachments populates the persistable state 1`] = `
Object {
"page": 1,
"per_page": 1,
"saved_objects": Array [
Object {
"attributes": Object {
"action": "create",
"comment_id": "persistable-state-test-id",
"created_at": "abc",
"created_by": Object {
"email": "a",
"full_name": "abc",
"username": "b",
},
"owner": "securitySolution",
"payload": Object {
"comment": Object {
"owner": "securitySolutionFixture",
"persistableStateAttachmentState": Object {
"foo": "foo",
"injectedId": "testRef",
},
"persistableStateAttachmentTypeId": ".test",
"type": "persistableState",
},
},
"type": "comment",
},
"id": "100",
"references": Array [
Object {
"id": "testRef",
"name": "myTestReference",
"type": "test-so",
},
Object {
"id": "1",
"name": "associated-cases",
"type": "cases",
},
Object {
"id": "persistable-state-test-id",
"name": "associated-cases-comments",
"type": "cases-comments",
},
],
"score": 0,
"type": "cases-user-actions",
},
],
"total": 1,
}
`;
exports[`transform transformFindResponseToExternalModel preserves the saved object fields and attributes when inject the ids 1`] = `
Object {
"page": 1,
"per_page": 1,
"saved_objects": Array [
Object {
"attributes": Object {
"action": "create",
"comment_id": null,
"created_at": "abc",
"created_by": Object {
"email": "a",
"full_name": "abc",
"username": "b",
},
"owner": "securitySolution",
"payload": Object {
"connector": Object {
"fields": Object {
"issueType": "bug",
"parent": "2",
"priority": "high",
},
"id": "1",
"name": ".jira",
"type": ".jira",
},
},
"type": "connector",
},
"id": "100",
"references": Array [
Object {
"id": "1",
"name": "associated-cases",
"type": "cases",
},
Object {
"id": "1",
"name": "connectorId",
"type": "action",
},
],
"score": 0,
"type": "cases-user-actions",
},
],
"total": 1,
}
`;

View file

@ -5,47 +5,20 @@
* 2.0.
*/
import { get, omit } from 'lodash';
import { loggerMock } from '@kbn/logging-mocks';
import { savedObjectsClientMock } from '@kbn/core/server/mocks';
import type {
SavedObject,
SavedObjectReference,
SavedObjectsBulkCreateObject,
SavedObjectsFindResponse,
SavedObjectsFindResult,
SavedObjectsUpdateResponse,
} from '@kbn/core/server';
import { ACTION_SAVED_OBJECT_TYPE } from '@kbn/actions-plugin/server';
import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks';
import type {
CaseAttributes,
CaseUserActionAttributes,
ConnectorUserAction,
UserAction,
} from '../../../common/api';
import type { CaseAttributes } from '../../../common/api';
import { Actions, ActionTypes, CaseSeverity, CaseStatuses } from '../../../common/api';
import {
CASE_COMMENT_SAVED_OBJECT,
CASE_SAVED_OBJECT,
CASE_USER_ACTION_SAVED_OBJECT,
SECURITY_SOLUTION_OWNER,
} from '../../../common/constants';
import {
CASE_REF_NAME,
COMMENT_REF_NAME,
CONNECTOR_ID_REFERENCE_NAME,
EXTERNAL_REFERENCE_REF_NAME,
PUSH_CONNECTOR_ID_REFERENCE_NAME,
} from '../../common/constants';
import { SECURITY_SOLUTION_OWNER } from '../../../common/constants';
import {
createCaseSavedObjectResponse,
createConnectorObject,
createExternalService,
createJiraConnector,
createSOFindResponse,
} from '../test_utils';
import { createCaseSavedObjectResponse } from '../test_utils';
import {
casePayload,
externalService,
@ -57,255 +30,10 @@ import {
originalCasesWithAssignee,
updatedTagsCases,
} from './mocks';
import { CaseUserActionService, transformFindResponseToExternalModel } from '.';
import type { PersistableStateAttachmentTypeRegistry } from '../../attachment_framework/persistable_state_registry';
import {
externalReferenceAttachmentSO,
createPersistableStateAttachmentTypeRegistryMock,
persistableStateAttachment,
} from '../../attachment_framework/mocks';
import { CaseUserActionService } from '.';
import { createPersistableStateAttachmentTypeRegistryMock } from '../../attachment_framework/mocks';
import { serializerMock } from '@kbn/core-saved-objects-base-server-mocks';
const createConnectorUserAction = (
overrides?: Partial<CaseUserActionAttributes>
): SavedObject<CaseUserActionAttributes> => {
const { id, ...restConnector } = createConnectorObject().connector;
return {
...createUserActionSO({
action: Actions.create,
payload: { connector: restConnector },
type: 'connector',
connectorId: id,
}),
...(overrides && { ...overrides }),
};
};
const updateConnectorUserAction = ({
overrides,
}: {
overrides?: Partial<CaseUserActionAttributes>;
} = {}): SavedObject<CaseUserActionAttributes> => {
const { id, ...restConnector } = createJiraConnector();
return {
...createUserActionSO({
action: Actions.update,
payload: { connector: restConnector },
type: 'connector',
connectorId: id,
}),
...(overrides && { ...overrides }),
};
};
const pushConnectorUserAction = ({
overrides,
}: {
overrides?: Partial<CaseUserActionAttributes>;
} = {}): SavedObject<CaseUserActionAttributes> => {
const { connector_id: connectorId, ...restExternalService } = createExternalService();
return {
...createUserActionSO({
action: Actions.push_to_service,
payload: { externalService: restExternalService },
pushedConnectorId: connectorId,
type: 'pushed',
}),
...(overrides && { ...overrides }),
};
};
const createCaseUserAction = (): SavedObject<CaseUserActionAttributes> => {
const { id, ...restConnector } = createJiraConnector();
return {
...createUserActionSO({
action: Actions.create,
payload: {
connector: restConnector,
title: 'a title',
description: 'a desc',
settings: { syncAlerts: false },
status: CaseStatuses.open,
severity: CaseSeverity.LOW,
tags: [],
owner: SECURITY_SOLUTION_OWNER,
},
connectorId: id,
type: 'create_case',
}),
};
};
const createUserActionFindSO = (
userAction: SavedObject<CaseUserActionAttributes>
): SavedObjectsFindResult<CaseUserActionAttributes> => ({
...userAction,
score: 0,
});
const createUserActionSO = ({
action,
attributesOverrides,
commentId,
connectorId,
pushedConnectorId,
payload,
type,
references = [],
}: {
action: UserAction;
type?: string;
payload?: Record<string, unknown>;
attributesOverrides?: Partial<CaseUserActionAttributes>;
commentId?: string;
connectorId?: string;
pushedConnectorId?: string;
references?: SavedObjectReference[];
}): SavedObject<CaseUserActionAttributes> => {
const defaultParams = {
action,
created_at: 'abc',
created_by: {
email: 'a',
username: 'b',
full_name: 'abc',
},
type: type ?? 'title',
payload: payload ?? { title: 'a new title' },
owner: 'securitySolution',
};
return {
type: CASE_USER_ACTION_SAVED_OBJECT,
id: '100',
attributes: {
...defaultParams,
...(attributesOverrides && { ...attributesOverrides }),
},
references: [
...references,
{
type: CASE_SAVED_OBJECT,
name: CASE_REF_NAME,
id: '1',
},
...(commentId
? [
{
type: CASE_COMMENT_SAVED_OBJECT,
name: COMMENT_REF_NAME,
id: commentId,
},
]
: []),
...(connectorId
? [
{
type: ACTION_SAVED_OBJECT_TYPE,
name: CONNECTOR_ID_REFERENCE_NAME,
id: connectorId,
},
]
: []),
...(pushedConnectorId
? [
{
type: ACTION_SAVED_OBJECT_TYPE,
name: PUSH_CONNECTOR_ID_REFERENCE_NAME,
id: pushedConnectorId,
},
]
: []),
],
} as SavedObject<CaseUserActionAttributes>;
};
const createPersistableStateUserAction = () => {
return {
...createUserActionSO({
action: Actions.create,
commentId: 'persistable-state-test-id',
payload: {
comment: {
...persistableStateAttachment,
persistableStateAttachmentState: { foo: 'foo' },
},
},
type: 'comment',
references: [{ id: 'testRef', name: 'myTestReference', type: 'test-so' }],
}),
};
};
const createExternalReferenceUserAction = () => {
return {
...createUserActionSO({
action: Actions.create,
commentId: 'external-reference-test-id',
payload: {
comment: omit(externalReferenceAttachmentSO, 'externalReferenceId'),
},
type: 'comment',
references: [{ id: 'my-id', name: EXTERNAL_REFERENCE_REF_NAME, type: 'test-so' }],
}),
};
};
const testConnectorId = (
persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry,
userAction: SavedObject<CaseUserActionAttributes>,
path: string,
expectedConnectorId = '1'
) => {
it('does set payload.connector.id to none when it cannot find the reference', () => {
const userActionWithEmptyRef = { ...userAction, references: [] };
const transformed = transformFindResponseToExternalModel(
createSOFindResponse([createUserActionFindSO(userActionWithEmptyRef)]),
persistableStateAttachmentTypeRegistry
);
expect(get(transformed.saved_objects[0].attributes.payload, path)).toBe('none');
});
it('does not populate the payload.connector.id when the reference exists but the action is not of type connector', () => {
const invalidUserAction = {
...userAction,
attributes: { ...userAction.attributes, type: 'not-connector' },
};
const transformed = transformFindResponseToExternalModel(
createSOFindResponse([
createUserActionFindSO(invalidUserAction as SavedObject<CaseUserActionAttributes>),
]),
persistableStateAttachmentTypeRegistry
);
expect(get(transformed.saved_objects[0].attributes.payload, path)).toBeUndefined();
});
it('does not populate the payload.connector.id when the reference exists but the payload does not contain a connector', () => {
const invalidUserAction = {
...userAction,
attributes: { ...userAction.attributes, payload: {} },
};
const transformed = transformFindResponseToExternalModel(
createSOFindResponse([
createUserActionFindSO(invalidUserAction as SavedObject<CaseUserActionAttributes>),
]),
persistableStateAttachmentTypeRegistry
) as SavedObjectsFindResponse<ConnectorUserAction>;
expect(get(transformed.saved_objects[0].attributes.payload, path)).toBeUndefined();
});
it('populates the payload.connector.id', () => {
const transformed = transformFindResponseToExternalModel(
createSOFindResponse([createUserActionFindSO(userAction)]),
persistableStateAttachmentTypeRegistry
) as SavedObjectsFindResponse<ConnectorUserAction>;
expect(get(transformed.saved_objects[0].attributes.payload, path)).toEqual(expectedConnectorId);
});
};
import { createUserActionFindSO, createConnectorUserAction } from './test_utils';
describe('CaseUserActionService', () => {
const persistableStateAttachmentTypeRegistry = createPersistableStateAttachmentTypeRegistryMock();
@ -319,322 +47,6 @@ describe('CaseUserActionService', () => {
jest.useRealTimers();
});
describe('transformFindResponseToExternalModel', () => {
it('does not populate the ids when the response is an empty array', () => {
expect(
transformFindResponseToExternalModel(
createSOFindResponse([]),
persistableStateAttachmentTypeRegistry
)
).toMatchInlineSnapshot(`
Object {
"page": 1,
"per_page": 0,
"saved_objects": Array [],
"total": 0,
}
`);
});
it('preserves the saved object fields and attributes when inject the ids', () => {
const transformed = transformFindResponseToExternalModel(
createSOFindResponse([createUserActionFindSO(createConnectorUserAction())]),
persistableStateAttachmentTypeRegistry
);
expect(transformed).toMatchInlineSnapshot(`
Object {
"page": 1,
"per_page": 1,
"saved_objects": Array [
Object {
"attributes": Object {
"action": "create",
"action_id": "100",
"case_id": "1",
"comment_id": null,
"created_at": "abc",
"created_by": Object {
"email": "a",
"full_name": "abc",
"username": "b",
},
"owner": "securitySolution",
"payload": Object {
"connector": Object {
"fields": Object {
"issueType": "bug",
"parent": "2",
"priority": "high",
},
"id": "1",
"name": ".jira",
"type": ".jira",
},
},
"type": "connector",
},
"id": "100",
"references": Array [
Object {
"id": "1",
"name": "associated-cases",
"type": "cases",
},
Object {
"id": "1",
"name": "connectorId",
"type": "action",
},
],
"score": 0,
"type": "cases-user-actions",
},
],
"total": 1,
}
`);
});
it('populates the payload.connector.id for multiple user actions', () => {
const transformed = transformFindResponseToExternalModel(
createSOFindResponse([
createUserActionFindSO(createConnectorUserAction()),
createUserActionFindSO(createConnectorUserAction()),
]),
persistableStateAttachmentTypeRegistry
) as SavedObjectsFindResponse<ConnectorUserAction>;
expect(transformed.saved_objects[0].attributes.payload.connector.id).toEqual('1');
expect(transformed.saved_objects[1].attributes.payload.connector.id).toEqual('1');
});
describe('reference ids', () => {
it('sets case_id to an empty string when it cannot find the reference', () => {
const userAction = {
...createConnectorUserAction(),
references: [],
};
const transformed = transformFindResponseToExternalModel(
createSOFindResponse([createUserActionFindSO(userAction)]),
persistableStateAttachmentTypeRegistry
);
expect(transformed.saved_objects[0].attributes.case_id).toEqual('');
});
it('sets comment_id to null when it cannot find the reference', () => {
const userAction = {
...createUserActionSO({ action: Actions.create, commentId: '5' }),
references: [],
};
const transformed = transformFindResponseToExternalModel(
createSOFindResponse([createUserActionFindSO(userAction)]),
persistableStateAttachmentTypeRegistry
);
expect(transformed.saved_objects[0].attributes.comment_id).toBeNull();
});
it('sets case_id correctly when it finds the reference', () => {
const userAction = createConnectorUserAction();
const transformed = transformFindResponseToExternalModel(
createSOFindResponse([createUserActionFindSO(userAction)]),
persistableStateAttachmentTypeRegistry
);
expect(transformed.saved_objects[0].attributes.case_id).toEqual('1');
});
it('sets comment_id correctly when it finds the reference', () => {
const userAction = createUserActionSO({
action: Actions.create,
commentId: '5',
});
const transformed = transformFindResponseToExternalModel(
createSOFindResponse([createUserActionFindSO(userAction)]),
persistableStateAttachmentTypeRegistry
);
expect(transformed.saved_objects[0].attributes.comment_id).toEqual('5');
});
it('sets action_id correctly to the saved object id', () => {
const userAction = {
...createUserActionSO({ action: Actions.create, commentId: '5' }),
};
const transformed = transformFindResponseToExternalModel(
createSOFindResponse([createUserActionFindSO(userAction)]),
persistableStateAttachmentTypeRegistry
);
expect(transformed.saved_objects[0].attributes.action_id).toEqual('100');
});
});
describe('create connector', () => {
const userAction = createConnectorUserAction();
testConnectorId(persistableStateAttachmentTypeRegistry, userAction, 'connector.id');
});
describe('update connector', () => {
const userAction = updateConnectorUserAction();
testConnectorId(persistableStateAttachmentTypeRegistry, userAction, 'connector.id');
});
describe('push connector', () => {
const userAction = pushConnectorUserAction();
testConnectorId(
persistableStateAttachmentTypeRegistry,
userAction,
'externalService.connector_id',
'100'
);
});
describe('create case', () => {
const userAction = createCaseUserAction();
testConnectorId(persistableStateAttachmentTypeRegistry, userAction, 'connector.id');
});
describe('persistable state attachments', () => {
it('populates the persistable state', () => {
const transformed = transformFindResponseToExternalModel(
createSOFindResponse([createUserActionFindSO(createPersistableStateUserAction())]),
persistableStateAttachmentTypeRegistry
) as SavedObjectsFindResponse<ConnectorUserAction>;
expect(transformed).toMatchInlineSnapshot(`
Object {
"page": 1,
"per_page": 1,
"saved_objects": Array [
Object {
"attributes": Object {
"action": "create",
"action_id": "100",
"case_id": "1",
"comment_id": "persistable-state-test-id",
"created_at": "abc",
"created_by": Object {
"email": "a",
"full_name": "abc",
"username": "b",
},
"owner": "securitySolution",
"payload": Object {
"comment": Object {
"owner": "securitySolutionFixture",
"persistableStateAttachmentState": Object {
"foo": "foo",
"injectedId": "testRef",
},
"persistableStateAttachmentTypeId": ".test",
"type": "persistableState",
},
},
"type": "comment",
},
"id": "100",
"references": Array [
Object {
"id": "testRef",
"name": "myTestReference",
"type": "test-so",
},
Object {
"id": "1",
"name": "associated-cases",
"type": "cases",
},
Object {
"id": "persistable-state-test-id",
"name": "associated-cases-comments",
"type": "cases-comments",
},
],
"score": 0,
"type": "cases-user-actions",
},
],
"total": 1,
}
`);
});
});
describe('external references', () => {
it('populates the external references attributes', () => {
const transformed = transformFindResponseToExternalModel(
createSOFindResponse([createUserActionFindSO(createExternalReferenceUserAction())]),
persistableStateAttachmentTypeRegistry
) as SavedObjectsFindResponse<ConnectorUserAction>;
expect(transformed).toMatchInlineSnapshot(`
Object {
"page": 1,
"per_page": 1,
"saved_objects": Array [
Object {
"attributes": Object {
"action": "create",
"action_id": "100",
"case_id": "1",
"comment_id": "external-reference-test-id",
"created_at": "abc",
"created_by": Object {
"email": "a",
"full_name": "abc",
"username": "b",
},
"owner": "securitySolution",
"payload": Object {
"comment": Object {
"externalReferenceAttachmentTypeId": ".test",
"externalReferenceId": "my-id",
"externalReferenceMetadata": null,
"externalReferenceStorage": Object {
"soType": "test-so",
"type": "savedObject",
},
"owner": "securitySolution",
"type": "externalReference",
},
},
"type": "comment",
},
"id": "100",
"references": Array [
Object {
"id": "my-id",
"name": "externalReferenceId",
"type": "test-so",
},
Object {
"id": "1",
"name": "associated-cases",
"type": "cases",
},
Object {
"id": "external-reference-test-id",
"name": "associated-cases-comments",
"type": "cases-comments",
},
],
"score": 0,
"type": "cases-user-actions",
},
],
"total": 1,
}
`);
});
});
});
describe('methods', () => {
let service: CaseUserActionService;
const unsecuredSavedObjectsClient = savedObjectsClientMock.create();

View file

@ -14,39 +14,23 @@ import type {
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { KueryNode } from '@kbn/es-query';
import { isCommentRequestTypePersistableState } from '../../../common/utils/attachments';
import {
isConnectorUserAction,
isPushedUserAction,
isCreateCaseUserAction,
isCommentUserAction,
} from '../../../common/utils/user_actions';
import type {
CaseUserActionAttributes,
CaseUserActionAttributesWithoutConnectorId,
CaseUserActionInjectedAttributesWithoutActionId,
CaseUserActionResponse,
} from '../../../common/api';
import { Actions, ActionTypes, NONE_CONNECTOR_ID } from '../../../common/api';
import { ActionTypes } from '../../../common/api';
import {
CASE_SAVED_OBJECT,
CASE_USER_ACTION_SAVED_OBJECT,
MAX_DOCS_PER_PAGE,
CASE_COMMENT_SAVED_OBJECT,
} from '../../../common/constants';
import {
CASE_REF_NAME,
COMMENT_REF_NAME,
CONNECTOR_ID_REFERENCE_NAME,
EXTERNAL_REFERENCE_REF_NAME,
PUSH_CONNECTOR_ID_REFERENCE_NAME,
} from '../../common/constants';
import { findConnectorIdReference } from '../transform';
import { buildFilter, combineFilters } from '../../client/utils';
import type { CaseConnectorActivity, CaseConnectorFields, PushInfo, ServiceContext } from './types';
import { defaultSortField, isCommentRequestTypeExternalReferenceSO } from '../../common/utils';
import type { PersistableStateAttachmentTypeRegistry } from '../../attachment_framework/persistable_state_registry';
import { injectPersistableReferencesToSO } from '../../attachment_framework/so_references';
import { defaultSortField } from '../../common/utils';
import { UserActionPersister } from './operations/create';
import { UserActionFinder } from './operations/find';
import { transformToExternalModel, legacyTransformFindResponseToExternalModel } from './transform';
export interface UserActionItem {
attributes: CaseUserActionAttributesWithoutConnectorId;
@ -97,15 +81,21 @@ interface ConnectorFieldsBeforePushAggsResult {
export class CaseUserActionService {
private readonly _creator: UserActionPersister;
private readonly _finder: UserActionFinder;
constructor(private readonly context: ServiceContext) {
this._creator = new UserActionPersister(context);
this._finder = new UserActionFinder(context);
}
public get creator() {
return this._creator;
}
public get finder() {
return this._finder;
}
public async getConnectorFieldsBeforeLatestPush(
caseId: string,
pushes: PushInfo[]
@ -271,7 +261,7 @@ export class CaseUserActionService {
public async getMostRecentUserAction(
caseId: string
): Promise<SavedObject<CaseUserActionResponse> | undefined> {
): Promise<SavedObject<CaseUserActionInjectedAttributesWithoutActionId> | undefined> {
try {
this.context.log.debug(
`Attempting to retrieve the most recent user action for case id: ${caseId}`
@ -378,7 +368,7 @@ export class CaseUserActionService {
rawFieldsDoc = createCase.mostRecent.hits.hits[0];
}
let fieldsDoc: SavedObject<CaseUserActionResponse> | undefined;
let fieldsDoc: SavedObject<CaseUserActionInjectedAttributesWithoutActionId> | undefined;
if (rawFieldsDoc != null) {
const doc =
this.context.savedObjectsSerializer.rawToSavedObject<CaseUserActionAttributesWithoutConnectorId>(
@ -392,7 +382,7 @@ export class CaseUserActionService {
}
const pushInfo = connectorInfo.reverse.connectorActivity.buckets.pushInfo;
let pushDoc: SavedObject<CaseUserActionResponse> | undefined;
let pushDoc: SavedObject<CaseUserActionInjectedAttributesWithoutActionId> | undefined;
if (pushInfo.mostRecent.hits.hits.length > 0) {
const rawPushDoc = pushInfo.mostRecent.hits.hits[0];
@ -520,7 +510,7 @@ export class CaseUserActionService {
}
);
return transformFindResponseToExternalModel(
return legacyTransformFindResponseToExternalModel(
userActions,
this.context.persistableStateAttachmentTypeRegistry
);
@ -562,60 +552,6 @@ export class CaseUserActionService {
}
}
public async findStatusChanges({
caseId,
filter,
}: {
caseId: string;
filter?: KueryNode;
}): Promise<Array<SavedObject<CaseUserActionResponse>>> {
try {
this.context.log.debug('Attempting to find status changes');
const updateActionFilter = buildFilter({
filters: Actions.update,
field: 'action',
operator: 'or',
type: CASE_USER_ACTION_SAVED_OBJECT,
});
const statusChangeFilter = buildFilter({
filters: ActionTypes.status,
field: 'type',
operator: 'or',
type: CASE_USER_ACTION_SAVED_OBJECT,
});
const combinedFilters = combineFilters([updateActionFilter, statusChangeFilter, filter]);
const finder =
this.context.unsecuredSavedObjectsClient.createPointInTimeFinder<CaseUserActionAttributesWithoutConnectorId>(
{
type: CASE_USER_ACTION_SAVED_OBJECT,
hasReference: { type: CASE_SAVED_OBJECT, id: caseId },
sortField: defaultSortField,
sortOrder: 'asc',
filter: combinedFilters,
perPage: MAX_DOCS_PER_PAGE,
}
);
let userActions: Array<SavedObject<CaseUserActionResponse>> = [];
for await (const findResults of finder.find()) {
userActions = userActions.concat(
findResults.saved_objects.map((so) =>
transformToExternalModel(so, this.context.persistableStateAttachmentTypeRegistry)
)
);
}
return userActions;
} catch (error) {
this.context.log.error(`Error finding status changes: ${error}`);
throw error;
}
}
public async getUniqueConnectors({
caseId,
filter,
@ -691,126 +627,3 @@ export class CaseUserActionService {
};
}
}
export function transformFindResponseToExternalModel(
userActions: SavedObjectsFindResponse<CaseUserActionAttributesWithoutConnectorId>,
persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry
): SavedObjectsFindResponse<CaseUserActionResponse> {
return {
...userActions,
saved_objects: userActions.saved_objects.map((so) => ({
...so,
...transformToExternalModel(so, persistableStateAttachmentTypeRegistry),
})),
};
}
function transformToExternalModel(
userAction: SavedObject<CaseUserActionAttributesWithoutConnectorId>,
persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry
): SavedObject<CaseUserActionResponse> {
const { references } = userAction;
const caseId = findReferenceId(CASE_REF_NAME, CASE_SAVED_OBJECT, references) ?? '';
const commentId =
findReferenceId(COMMENT_REF_NAME, CASE_COMMENT_SAVED_OBJECT, references) ?? null;
const payload = addReferenceIdToPayload(userAction, persistableStateAttachmentTypeRegistry);
return {
...userAction,
attributes: {
...userAction.attributes,
action_id: userAction.id,
case_id: caseId,
comment_id: commentId,
payload,
} as CaseUserActionResponse,
};
}
const addReferenceIdToPayload = (
userAction: SavedObject<CaseUserActionAttributes>,
persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry
): CaseUserActionAttributes['payload'] => {
const connectorId = getConnectorIdFromReferences(userAction);
const userActionAttributes = userAction.attributes;
if (isConnectorUserAction(userActionAttributes) || isCreateCaseUserAction(userActionAttributes)) {
return {
...userActionAttributes.payload,
connector: {
...userActionAttributes.payload.connector,
id: connectorId ?? NONE_CONNECTOR_ID,
},
};
} else if (isPushedUserAction(userActionAttributes)) {
return {
...userAction.attributes.payload,
externalService: {
...userActionAttributes.payload.externalService,
connector_id: connectorId ?? NONE_CONNECTOR_ID,
},
};
} else if (isCommentUserAction(userActionAttributes)) {
if (isCommentRequestTypeExternalReferenceSO(userActionAttributes.payload.comment)) {
const externalReferenceId = findReferenceId(
EXTERNAL_REFERENCE_REF_NAME,
userActionAttributes.payload.comment.externalReferenceStorage.soType,
userAction.references
);
return {
...userAction.attributes.payload,
comment: {
...userActionAttributes.payload.comment,
externalReferenceId: externalReferenceId ?? '',
},
};
}
if (isCommentRequestTypePersistableState(userActionAttributes.payload.comment)) {
const injectedAttributes = injectPersistableReferencesToSO(
userActionAttributes.payload.comment,
userAction.references,
{
persistableStateAttachmentTypeRegistry,
}
);
return {
...userAction.attributes.payload,
comment: {
...userActionAttributes.payload.comment,
...injectedAttributes,
},
};
}
}
return userAction.attributes.payload;
};
function getConnectorIdFromReferences(
userAction: SavedObject<CaseUserActionAttributes>
): string | null {
const { references } = userAction;
if (
isConnectorUserAction(userAction.attributes) ||
isCreateCaseUserAction(userAction.attributes)
) {
return findConnectorIdReference(CONNECTOR_ID_REFERENCE_NAME, references)?.id ?? null;
} else if (isPushedUserAction(userAction.attributes)) {
return findConnectorIdReference(PUSH_CONNECTOR_ID_REFERENCE_NAME, references)?.id ?? null;
}
return null;
}
function findReferenceId(
name: string,
type: string,
references: SavedObjectReference[]
): string | undefined {
return references.find((ref) => ref.name === name && ref.type === type)?.id;
}

View file

@ -0,0 +1,228 @@
/*
* 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 { KueryNode } from '@kbn/es-query';
import { fromKueryExpression } from '@kbn/es-query';
import type { SavedObjectsFindResponse } from '@kbn/core-saved-objects-api-server';
import type { SavedObject } from '@kbn/core-saved-objects-common';
import { DEFAULT_PAGE, DEFAULT_PER_PAGE } from '../../../routes/api';
import { defaultSortField } from '../../../common/utils';
import type {
CaseUserActionAttributesWithoutConnectorId,
UserActionFindRequest,
ActionTypeValues,
FindTypeField,
CaseUserActionInjectedAttributesWithoutActionId,
} from '../../../../common/api';
import { Actions, ActionTypes, CommentType } from '../../../../common/api';
import {
CASE_SAVED_OBJECT,
CASE_USER_ACTION_SAVED_OBJECT,
MAX_DOCS_PER_PAGE,
} from '../../../../common/constants';
import type { ServiceContext } from '../types';
import { transformFindResponseToExternalModel, transformToExternalModel } from '../transform';
import { buildFilter, combineFilters, NodeBuilderOperators } from '../../../client/utils';
interface FindOptions extends UserActionFindRequest {
caseId: string;
filter?: KueryNode;
}
export class UserActionFinder {
constructor(private readonly context: ServiceContext) {}
public async find({
caseId,
sortOrder,
types,
page,
perPage,
filter,
}: FindOptions): Promise<
SavedObjectsFindResponse<CaseUserActionInjectedAttributesWithoutActionId>
> {
try {
this.context.log.debug(`Attempting to find user actions for case id: ${caseId}`);
const finalFilter = combineFilters([filter, UserActionFinder.buildFilter(types)]);
const userActions =
await this.context.unsecuredSavedObjectsClient.find<CaseUserActionAttributesWithoutConnectorId>(
{
type: CASE_USER_ACTION_SAVED_OBJECT,
hasReference: { type: CASE_SAVED_OBJECT, id: caseId },
page: page ?? DEFAULT_PAGE,
perPage: perPage ?? DEFAULT_PER_PAGE,
sortField: 'created_at',
sortOrder: sortOrder ?? 'asc',
filter: finalFilter,
}
);
return transformFindResponseToExternalModel(
userActions,
this.context.persistableStateAttachmentTypeRegistry
);
} catch (error) {
this.context.log.error(`Error finding user actions for case id: ${caseId}: ${error}`);
throw error;
}
}
private static buildFilter(types: FindOptions['types'] = []) {
const filters = types.map((type) => UserActionFinder.buildFilterType(type));
return combineFilters(filters, NodeBuilderOperators.or);
}
private static buildFilterType(type: FindTypeField): KueryNode | undefined {
switch (type) {
case 'action':
return UserActionFinder.buildActionFilter();
case 'user':
return UserActionFinder.buildCommentTypeFilter();
case 'alert':
return UserActionFinder.buildAlertCommentTypeFilter();
case 'attachment':
return UserActionFinder.buildAttachmentsFilter();
default:
return UserActionFinder.buildGenericTypeFilter(type);
}
}
private static buildActionFilter(): KueryNode | undefined {
const filterForUserActionsExcludingComment = fromKueryExpression(
`not ${CASE_USER_ACTION_SAVED_OBJECT}.attributes.payload.comment.type: ${CommentType.user}`
);
return filterForUserActionsExcludingComment;
}
private static buildCommentTypeFilter(): KueryNode | undefined {
return combineFilters(
[
buildFilter({
filters: [ActionTypes.comment],
field: 'type',
operator: 'or',
type: CASE_USER_ACTION_SAVED_OBJECT,
}),
buildFilter({
filters: [CommentType.user],
field: 'payload.comment.type',
operator: 'or',
type: CASE_USER_ACTION_SAVED_OBJECT,
}),
],
NodeBuilderOperators.and
);
}
private static buildAlertCommentTypeFilter(): KueryNode | undefined {
return combineFilters(
[
buildFilter({
filters: [ActionTypes.comment],
field: 'type',
operator: 'or',
type: CASE_USER_ACTION_SAVED_OBJECT,
}),
buildFilter({
filters: [CommentType.alert],
field: 'payload.comment.type',
operator: 'or',
type: CASE_USER_ACTION_SAVED_OBJECT,
}),
],
NodeBuilderOperators.and
);
}
private static buildAttachmentsFilter(): KueryNode | undefined {
return combineFilters(
[
buildFilter({
filters: [ActionTypes.comment],
field: 'type',
operator: 'or',
type: CASE_USER_ACTION_SAVED_OBJECT,
}),
buildFilter({
filters: [CommentType.persistableState, CommentType.externalReference],
field: 'payload.comment.type',
operator: 'or',
type: CASE_USER_ACTION_SAVED_OBJECT,
}),
],
NodeBuilderOperators.and
);
}
private static buildGenericTypeFilter(type: ActionTypeValues): KueryNode | undefined {
return buildFilter({
filters: [type],
field: 'type',
operator: 'or',
type: CASE_USER_ACTION_SAVED_OBJECT,
});
}
public async findStatusChanges({
caseId,
filter,
}: {
caseId: string;
filter?: KueryNode;
}): Promise<Array<SavedObject<CaseUserActionInjectedAttributesWithoutActionId>>> {
try {
this.context.log.debug('Attempting to find status changes');
const updateActionFilter = buildFilter({
filters: Actions.update,
field: 'action',
operator: 'or',
type: CASE_USER_ACTION_SAVED_OBJECT,
});
const statusChangeFilter = buildFilter({
filters: ActionTypes.status,
field: 'type',
operator: 'or',
type: CASE_USER_ACTION_SAVED_OBJECT,
});
const combinedFilters = combineFilters([updateActionFilter, statusChangeFilter, filter]);
const finder =
this.context.unsecuredSavedObjectsClient.createPointInTimeFinder<CaseUserActionAttributesWithoutConnectorId>(
{
type: CASE_USER_ACTION_SAVED_OBJECT,
hasReference: { type: CASE_SAVED_OBJECT, id: caseId },
sortField: defaultSortField,
sortOrder: 'asc',
filter: combinedFilters,
perPage: MAX_DOCS_PER_PAGE,
}
);
let userActions: Array<SavedObject<CaseUserActionInjectedAttributesWithoutActionId>> = [];
for await (const findResults of finder.find()) {
userActions = userActions.concat(
findResults.saved_objects.map((so) =>
transformToExternalModel(so, this.context.persistableStateAttachmentTypeRegistry)
)
);
}
return userActions;
} catch (error) {
this.context.log.error(`Error finding status changes: ${error}`);
throw error;
}
}
}

View file

@ -0,0 +1,286 @@
/*
* 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 { ACTION_SAVED_OBJECT_TYPE } from '@kbn/actions-plugin/server';
import type {
SavedObjectsFindResponse,
SavedObjectsFindResult,
} from '@kbn/core-saved-objects-api-server';
import type { SavedObject, SavedObjectReference } from '@kbn/core-saved-objects-common';
import { omit, get } from 'lodash';
import {
CASE_COMMENT_SAVED_OBJECT,
CASE_SAVED_OBJECT,
CASE_USER_ACTION_SAVED_OBJECT,
SECURITY_SOLUTION_OWNER,
} from '../../../common/constants';
import type {
CaseUserActionAttributes,
ConnectorUserAction,
UserAction,
} from '../../../common/api';
import { CaseSeverity, CaseStatuses, Actions } from '../../../common/api';
import {
CASE_REF_NAME,
COMMENT_REF_NAME,
CONNECTOR_ID_REFERENCE_NAME,
EXTERNAL_REFERENCE_REF_NAME,
PUSH_CONNECTOR_ID_REFERENCE_NAME,
} from '../../common/constants';
import {
createConnectorObject,
createExternalService,
createJiraConnector,
createSOFindResponse,
} from '../test_utils';
import {
externalReferenceAttachmentSO,
persistableStateAttachment,
} from '../../attachment_framework/mocks';
import type { PersistableStateAttachmentTypeRegistry } from '../../attachment_framework/persistable_state_registry';
import { transformFindResponseToExternalModel } from './transform';
export const createUserActionFindSO = (
userAction: SavedObject<CaseUserActionAttributes>
): SavedObjectsFindResult<CaseUserActionAttributes> => ({
...userAction,
score: 0,
});
export const createConnectorUserAction = (
overrides?: Partial<CaseUserActionAttributes>
): SavedObject<CaseUserActionAttributes> => {
const { id, ...restConnector } = createConnectorObject().connector;
return {
...createUserActionSO({
action: Actions.create,
payload: { connector: restConnector },
type: 'connector',
connectorId: id,
}),
...(overrides && { ...overrides }),
};
};
export const createUserActionSO = ({
action,
attributesOverrides,
commentId,
connectorId,
pushedConnectorId,
payload,
type,
references = [],
}: {
action: UserAction;
type?: string;
payload?: Record<string, unknown>;
attributesOverrides?: Partial<CaseUserActionAttributes>;
commentId?: string;
connectorId?: string;
pushedConnectorId?: string;
references?: SavedObjectReference[];
}): SavedObject<CaseUserActionAttributes> => {
const defaultParams = {
action,
created_at: 'abc',
created_by: {
email: 'a',
username: 'b',
full_name: 'abc',
},
type: type ?? 'title',
payload: payload ?? { title: 'a new title' },
owner: 'securitySolution',
};
return {
type: CASE_USER_ACTION_SAVED_OBJECT,
id: '100',
attributes: {
...defaultParams,
...(attributesOverrides && { ...attributesOverrides }),
},
references: [
...references,
{
type: CASE_SAVED_OBJECT,
name: CASE_REF_NAME,
id: '1',
},
...(commentId
? [
{
type: CASE_COMMENT_SAVED_OBJECT,
name: COMMENT_REF_NAME,
id: commentId,
},
]
: []),
...(connectorId
? [
{
type: ACTION_SAVED_OBJECT_TYPE,
name: CONNECTOR_ID_REFERENCE_NAME,
id: connectorId,
},
]
: []),
...(pushedConnectorId
? [
{
type: ACTION_SAVED_OBJECT_TYPE,
name: PUSH_CONNECTOR_ID_REFERENCE_NAME,
id: pushedConnectorId,
},
]
: []),
],
} as SavedObject<CaseUserActionAttributes>;
};
export const updateConnectorUserAction = ({
overrides,
}: {
overrides?: Partial<CaseUserActionAttributes>;
} = {}): SavedObject<CaseUserActionAttributes> => {
const { id, ...restConnector } = createJiraConnector();
return {
...createUserActionSO({
action: Actions.update,
payload: { connector: restConnector },
type: 'connector',
connectorId: id,
}),
...(overrides && { ...overrides }),
};
};
export const pushConnectorUserAction = ({
overrides,
}: {
overrides?: Partial<CaseUserActionAttributes>;
} = {}): SavedObject<CaseUserActionAttributes> => {
const { connector_id: connectorId, ...restExternalService } = createExternalService();
return {
...createUserActionSO({
action: Actions.push_to_service,
payload: { externalService: restExternalService },
pushedConnectorId: connectorId,
type: 'pushed',
}),
...(overrides && { ...overrides }),
};
};
export const createCaseUserAction = (): SavedObject<CaseUserActionAttributes> => {
const { id, ...restConnector } = createJiraConnector();
return {
...createUserActionSO({
action: Actions.create,
payload: {
connector: restConnector,
title: 'a title',
description: 'a desc',
settings: { syncAlerts: false },
status: CaseStatuses.open,
severity: CaseSeverity.LOW,
tags: [],
owner: SECURITY_SOLUTION_OWNER,
},
connectorId: id,
type: 'create_case',
}),
};
};
export const createPersistableStateUserAction = () => {
return {
...createUserActionSO({
action: Actions.create,
commentId: 'persistable-state-test-id',
payload: {
comment: {
...persistableStateAttachment,
persistableStateAttachmentState: { foo: 'foo' },
},
},
type: 'comment',
references: [{ id: 'testRef', name: 'myTestReference', type: 'test-so' }],
}),
};
};
export const createExternalReferenceUserAction = () => {
return {
...createUserActionSO({
action: Actions.create,
commentId: 'external-reference-test-id',
payload: {
comment: omit(externalReferenceAttachmentSO, 'externalReferenceId'),
},
type: 'comment',
references: [{ id: 'my-id', name: EXTERNAL_REFERENCE_REF_NAME, type: 'test-so' }],
}),
};
};
export const testConnectorId = (
persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry,
userAction: SavedObject<CaseUserActionAttributes>,
path: string,
expectedConnectorId = '1'
) => {
it('does set payload.connector.id to none when it cannot find the reference', () => {
const userActionWithEmptyRef = { ...userAction, references: [] };
const transformed = transformFindResponseToExternalModel(
createSOFindResponse([createUserActionFindSO(userActionWithEmptyRef)]),
persistableStateAttachmentTypeRegistry
);
expect(get(transformed.saved_objects[0].attributes.payload, path)).toBe('none');
});
it('does not populate the payload.connector.id when the reference exists but the action is not of type connector', () => {
const invalidUserAction = {
...userAction,
attributes: { ...userAction.attributes, type: 'not-connector' },
};
const transformed = transformFindResponseToExternalModel(
createSOFindResponse([
createUserActionFindSO(invalidUserAction as SavedObject<CaseUserActionAttributes>),
]),
persistableStateAttachmentTypeRegistry
);
expect(get(transformed.saved_objects[0].attributes.payload, path)).toBeUndefined();
});
it('does not populate the payload.connector.id when the reference exists but the payload does not contain a connector', () => {
const invalidUserAction = {
...userAction,
attributes: { ...userAction.attributes, payload: {} },
};
const transformed = transformFindResponseToExternalModel(
createSOFindResponse([
createUserActionFindSO(invalidUserAction as SavedObject<CaseUserActionAttributes>),
]),
persistableStateAttachmentTypeRegistry
) as SavedObjectsFindResponse<ConnectorUserAction>;
expect(get(transformed.saved_objects[0].attributes.payload, path)).toBeUndefined();
});
it('populates the payload.connector.id', () => {
const transformed = transformFindResponseToExternalModel(
createSOFindResponse([createUserActionFindSO(userAction)]),
persistableStateAttachmentTypeRegistry
) as SavedObjectsFindResponse<ConnectorUserAction>;
expect(get(transformed.saved_objects[0].attributes.payload, path)).toEqual(expectedConnectorId);
});
};

View file

@ -0,0 +1,229 @@
/*
* 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 {
legacyTransformFindResponseToExternalModel,
transformFindResponseToExternalModel,
} from './transform';
import { createSOFindResponse } from '../test_utils';
import {
createUserActionFindSO,
createConnectorUserAction,
createUserActionSO,
updateConnectorUserAction,
pushConnectorUserAction,
createCaseUserAction,
createPersistableStateUserAction,
createExternalReferenceUserAction,
testConnectorId,
} from './test_utils';
import { createPersistableStateAttachmentTypeRegistryMock } from '../../attachment_framework/mocks';
import type { SavedObjectsFindResponse } from '@kbn/core-saved-objects-api-server';
import type { ConnectorUserAction } from '../../../common/api';
import { Actions } from '../../../common/api';
describe('transform', () => {
const persistableStateAttachmentTypeRegistry = createPersistableStateAttachmentTypeRegistryMock();
describe('action_id', () => {
it('legacyTransformFindResponseToExternalModel sets action_id correctly to the saved object id', () => {
const userAction = {
...createUserActionSO({ action: Actions.create, commentId: '5' }),
};
const transformed = legacyTransformFindResponseToExternalModel(
createSOFindResponse([createUserActionFindSO(userAction)]),
persistableStateAttachmentTypeRegistry
);
expect(transformed.saved_objects[0].attributes.action_id).toEqual('100');
});
it('transformFindResponseToExternalModel does not set action_id', () => {
const userAction = {
...createUserActionSO({ action: Actions.create, commentId: '5' }),
};
const transformed = transformFindResponseToExternalModel(
createSOFindResponse([createUserActionFindSO(userAction)]),
persistableStateAttachmentTypeRegistry
);
expect(transformed.saved_objects[0].attributes).not.toHaveProperty('action_id');
});
});
describe('case_id', () => {
describe('legacyTransformFindResponseToExternalModel', () => {
it('sets case_id correctly when it finds the reference', () => {
const userAction = createConnectorUserAction();
const transformed = legacyTransformFindResponseToExternalModel(
createSOFindResponse([createUserActionFindSO(userAction)]),
persistableStateAttachmentTypeRegistry
);
expect(transformed.saved_objects[0].attributes.case_id).toEqual('1');
});
it('sets case_id to an empty string when it cannot find the reference', () => {
const userAction = {
...createConnectorUserAction(),
references: [],
};
const transformed = legacyTransformFindResponseToExternalModel(
createSOFindResponse([createUserActionFindSO(userAction)]),
persistableStateAttachmentTypeRegistry
);
expect(transformed.saved_objects[0].attributes.case_id).toEqual('');
});
});
describe('transformFindResponseToExternalModel', () => {
it('does not set the case_id when the reference exists', () => {
const userAction = createConnectorUserAction();
const transformed = transformFindResponseToExternalModel(
createSOFindResponse([createUserActionFindSO(userAction)]),
persistableStateAttachmentTypeRegistry
);
expect(transformed.saved_objects[0].attributes).not.toHaveProperty('case_id');
});
it('does not set the case_id when the reference does not exist', () => {
const userAction = {
...createConnectorUserAction(),
references: [],
};
const transformed = transformFindResponseToExternalModel(
createSOFindResponse([createUserActionFindSO(userAction)]),
persistableStateAttachmentTypeRegistry
);
expect(transformed.saved_objects[0].attributes).not.toHaveProperty('case_id');
});
});
});
describe.each([
[transformFindResponseToExternalModel.name, transformFindResponseToExternalModel],
[legacyTransformFindResponseToExternalModel.name, legacyTransformFindResponseToExternalModel],
])('%s', (functionName, transformer) => {
it('does not populate the ids when the response is an empty array', () => {
expect(transformer(createSOFindResponse([]), persistableStateAttachmentTypeRegistry))
.toMatchInlineSnapshot(`
Object {
"page": 1,
"per_page": 0,
"saved_objects": Array [],
"total": 0,
}
`);
});
it('preserves the saved object fields and attributes when inject the ids', () => {
const transformed = transformer(
createSOFindResponse([createUserActionFindSO(createConnectorUserAction())]),
persistableStateAttachmentTypeRegistry
);
expect(transformed).toMatchSnapshot();
});
it('populates the payload.connector.id for multiple user actions', () => {
const transformed = transformer(
createSOFindResponse([
createUserActionFindSO(createConnectorUserAction()),
createUserActionFindSO(createConnectorUserAction()),
]),
persistableStateAttachmentTypeRegistry
) as SavedObjectsFindResponse<ConnectorUserAction>;
expect(transformed.saved_objects[0].attributes.payload.connector.id).toEqual('1');
expect(transformed.saved_objects[1].attributes.payload.connector.id).toEqual('1');
});
describe('reference ids', () => {
it('sets comment_id to null when it cannot find the reference', () => {
const userAction = {
...createUserActionSO({ action: Actions.create, commentId: '5' }),
references: [],
};
const transformed = transformer(
createSOFindResponse([createUserActionFindSO(userAction)]),
persistableStateAttachmentTypeRegistry
);
expect(transformed.saved_objects[0].attributes.comment_id).toBeNull();
});
it('sets comment_id correctly when it finds the reference', () => {
const userAction = createUserActionSO({
action: Actions.create,
commentId: '5',
});
const transformed = transformer(
createSOFindResponse([createUserActionFindSO(userAction)]),
persistableStateAttachmentTypeRegistry
);
expect(transformed.saved_objects[0].attributes.comment_id).toEqual('5');
});
});
describe('create connector', () => {
const userAction = createConnectorUserAction();
testConnectorId(persistableStateAttachmentTypeRegistry, userAction, 'connector.id');
});
describe('update connector', () => {
const userAction = updateConnectorUserAction();
testConnectorId(persistableStateAttachmentTypeRegistry, userAction, 'connector.id');
});
describe('push connector', () => {
const userAction = pushConnectorUserAction();
testConnectorId(
persistableStateAttachmentTypeRegistry,
userAction,
'externalService.connector_id',
'100'
);
});
describe('create case', () => {
const userAction = createCaseUserAction();
testConnectorId(persistableStateAttachmentTypeRegistry, userAction, 'connector.id');
});
describe('persistable state attachments', () => {
it('populates the persistable state', () => {
const transformed = transformer(
createSOFindResponse([createUserActionFindSO(createPersistableStateUserAction())]),
persistableStateAttachmentTypeRegistry
) as SavedObjectsFindResponse<ConnectorUserAction>;
expect(transformed).toMatchSnapshot();
});
});
describe('external references', () => {
it('populates the external references attributes', () => {
const transformed = transformer(
createSOFindResponse([createUserActionFindSO(createExternalReferenceUserAction())]),
persistableStateAttachmentTypeRegistry
) as SavedObjectsFindResponse<ConnectorUserAction>;
expect(transformed).toMatchSnapshot();
});
});
});
});

View file

@ -0,0 +1,200 @@
/*
* 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 { SavedObject, SavedObjectReference, SavedObjectsFindResponse } from '@kbn/core/server';
import { isCommentRequestTypePersistableState } from '../../../common/utils/attachments';
import {
isConnectorUserAction,
isPushedUserAction,
isCreateCaseUserAction,
isCommentUserAction,
} from '../../../common/utils/user_actions';
import type {
CaseUserActionAttributes,
CaseUserActionAttributesWithoutConnectorId,
CaseUserActionInjectedAttributesWithoutActionId,
CaseUserActionResponse,
} from '../../../common/api';
import { NONE_CONNECTOR_ID } from '../../../common/api';
import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../../../common/constants';
import {
CASE_REF_NAME,
COMMENT_REF_NAME,
CONNECTOR_ID_REFERENCE_NAME,
EXTERNAL_REFERENCE_REF_NAME,
PUSH_CONNECTOR_ID_REFERENCE_NAME,
} from '../../common/constants';
import { findConnectorIdReference } from '../transform';
import { isCommentRequestTypeExternalReferenceSO } from '../../common/utils';
import type { PersistableStateAttachmentTypeRegistry } from '../../attachment_framework/persistable_state_registry';
import { injectPersistableReferencesToSO } from '../../attachment_framework/so_references';
export function transformFindResponseToExternalModel(
userActions: SavedObjectsFindResponse<CaseUserActionAttributesWithoutConnectorId>,
persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry
): SavedObjectsFindResponse<CaseUserActionInjectedAttributesWithoutActionId> {
return {
...userActions,
saved_objects: userActions.saved_objects.map((so) => ({
...so,
...transformToExternalModel(so, persistableStateAttachmentTypeRegistry),
})),
};
}
export function transformToExternalModel(
userAction: SavedObject<CaseUserActionAttributesWithoutConnectorId>,
persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry
): SavedObject<CaseUserActionInjectedAttributesWithoutActionId> {
const { references } = userAction;
const commentId =
findReferenceId(COMMENT_REF_NAME, CASE_COMMENT_SAVED_OBJECT, references) ?? null;
const payload = addReferenceIdToPayload(userAction, persistableStateAttachmentTypeRegistry);
return {
...userAction,
attributes: {
...userAction.attributes,
comment_id: commentId,
payload,
} as CaseUserActionInjectedAttributesWithoutActionId,
};
}
/**
* This function should only be used in the getAll user actions and it is deprecated. It should be removed when the
* getAll route is removed.
*
* @deprecated remove when the getAllRoute is removed
*/
export function legacyTransformFindResponseToExternalModel(
userActions: SavedObjectsFindResponse<CaseUserActionAttributesWithoutConnectorId>,
persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry
): SavedObjectsFindResponse<CaseUserActionResponse> {
return {
...userActions,
saved_objects: userActions.saved_objects.map((so) => ({
...so,
...legacyTransformToExternalModel(so, persistableStateAttachmentTypeRegistry),
})),
};
}
/**
* @deprecated remove when the getAll route is removed
*/
function legacyTransformToExternalModel(
userAction: SavedObject<CaseUserActionAttributesWithoutConnectorId>,
persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry
): SavedObject<CaseUserActionResponse> {
const { references } = userAction;
const caseId = findReferenceId(CASE_REF_NAME, CASE_SAVED_OBJECT, references) ?? '';
const commentId =
findReferenceId(COMMENT_REF_NAME, CASE_COMMENT_SAVED_OBJECT, references) ?? null;
const payload = addReferenceIdToPayload(userAction, persistableStateAttachmentTypeRegistry);
return {
...userAction,
attributes: {
...userAction.attributes,
action_id: userAction.id,
case_id: caseId,
comment_id: commentId,
payload,
} as CaseUserActionResponse,
};
}
const addReferenceIdToPayload = (
userAction: SavedObject<CaseUserActionAttributes>,
persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry
): CaseUserActionAttributes['payload'] => {
const connectorId = getConnectorIdFromReferences(userAction);
const userActionAttributes = userAction.attributes;
if (isConnectorUserAction(userActionAttributes) || isCreateCaseUserAction(userActionAttributes)) {
return {
...userActionAttributes.payload,
connector: {
...userActionAttributes.payload.connector,
id: connectorId ?? NONE_CONNECTOR_ID,
},
};
} else if (isPushedUserAction(userActionAttributes)) {
return {
...userAction.attributes.payload,
externalService: {
...userActionAttributes.payload.externalService,
connector_id: connectorId ?? NONE_CONNECTOR_ID,
},
};
} else if (isCommentUserAction(userActionAttributes)) {
if (isCommentRequestTypeExternalReferenceSO(userActionAttributes.payload.comment)) {
const externalReferenceId = findReferenceId(
EXTERNAL_REFERENCE_REF_NAME,
userActionAttributes.payload.comment.externalReferenceStorage.soType,
userAction.references
);
return {
...userAction.attributes.payload,
comment: {
...userActionAttributes.payload.comment,
externalReferenceId: externalReferenceId ?? '',
},
};
}
if (isCommentRequestTypePersistableState(userActionAttributes.payload.comment)) {
const injectedAttributes = injectPersistableReferencesToSO(
userActionAttributes.payload.comment,
userAction.references,
{
persistableStateAttachmentTypeRegistry,
}
);
return {
...userAction.attributes.payload,
comment: {
...userActionAttributes.payload.comment,
...injectedAttributes,
},
};
}
}
return userAction.attributes.payload;
};
function getConnectorIdFromReferences(
userAction: SavedObject<CaseUserActionAttributes>
): string | null {
const { references } = userAction;
if (
isConnectorUserAction(userAction.attributes) ||
isCreateCaseUserAction(userAction.attributes)
) {
return findConnectorIdReference(CONNECTOR_ID_REFERENCE_NAME, references)?.id ?? null;
} else if (isPushedUserAction(userAction.attributes)) {
return findConnectorIdReference(PUSH_CONNECTOR_ID_REFERENCE_NAME, references)?.id ?? null;
}
return null;
}
function findReferenceId(
name: string,
type: string,
references: SavedObjectReference[]
): string | undefined {
return references.find((ref) => ref.name === name && ref.type === type)?.id;
}

View file

@ -19,7 +19,7 @@ import type {
CaseSettings,
CaseSeverity,
CaseStatuses,
CaseUserActionResponse,
CaseUserActionInjectedAttributesWithoutActionId,
CommentUserAction,
ConnectorUserAction,
PushedUserAction,
@ -145,11 +145,14 @@ export interface ServiceContext {
export interface CaseConnectorActivity {
connectorId: string;
fields: SavedObject<CaseUserActionResponse>;
push?: SavedObject<CaseUserActionResponse>;
fields: SavedObject<CaseUserActionInjectedAttributesWithoutActionId>;
push?: SavedObject<CaseUserActionInjectedAttributesWithoutActionId>;
}
export type CaseConnectorFields = Map<string, SavedObject<CaseUserActionResponse>>;
export type CaseConnectorFields = Map<
string,
SavedObject<CaseUserActionInjectedAttributesWithoutActionId>
>;
export interface PushInfo {
date: Date;

View file

@ -52,6 +52,7 @@
"@kbn/safer-lodash-set",
"@kbn/logging-mocks",
"@kbn/ecs",
"@kbn/core-saved-objects-api-server",
"@kbn/core-saved-objects-base-server-mocks",
],
"exclude": [

View file

@ -0,0 +1,70 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
UserActionFindResponse,
getCaseFindUserActionsUrl,
UserActionFindRequest,
CaseUserActionsResponse,
getCaseUserActionUrl,
CaseUserActionResponse,
} from '@kbn/cases-plugin/common/api';
import type SuperTest from 'supertest';
import { User } from './authentication/types';
import { superUser } from './authentication/users';
import { getSpaceUrlPrefix, removeServerGeneratedPropertiesFromObject } from './utils';
export const removeServerGeneratedPropertiesFromUserAction = (
attributes: CaseUserActionResponse
) => {
const keysToRemove: Array<keyof CaseUserActionResponse> = ['action_id', 'created_at'];
return removeServerGeneratedPropertiesFromObject<
CaseUserActionResponse,
typeof keysToRemove[number]
>(attributes, keysToRemove);
};
export const getCaseUserActions = async ({
supertest,
caseID,
expectedHttpCode = 200,
auth = { user: superUser, space: null },
}: {
supertest: SuperTest.SuperTest<SuperTest.Test>;
caseID: string;
expectedHttpCode?: number;
auth?: { user: User; space: string | null };
}): Promise<CaseUserActionsResponse> => {
const { body: userActions } = await supertest
.get(`${getSpaceUrlPrefix(auth.space)}${getCaseUserActionUrl(caseID)}`)
.auth(auth.user.username, auth.user.password)
.expect(expectedHttpCode);
return userActions;
};
export const findCaseUserActions = async ({
supertest,
caseID,
options = {},
expectedHttpCode = 200,
auth = { user: superUser, space: null },
}: {
supertest: SuperTest.SuperTest<SuperTest.Test>;
caseID: string;
options?: UserActionFindRequest;
expectedHttpCode?: number;
auth?: { user: User; space: string | null };
}): Promise<UserActionFindResponse> => {
const { body: userActions } = await supertest
.get(`${getSpaceUrlPrefix(auth.space)}${getCaseFindUserActionsUrl(caseID)}`)
.query(options)
.auth(auth.user.username, auth.user.password)
.expect(expectedHttpCode);
return userActions;
};

View file

@ -33,7 +33,6 @@ import {
CasesResponse,
CasesFindResponse,
CommentRequest,
CaseUserActionResponse,
CommentResponse,
CasesPatchRequest,
AllCommentsResponse,
@ -41,7 +40,6 @@ import {
CasesConfigurePatch,
CasesStatusResponse,
CasesConfigurationsResponse,
CaseUserActionsResponse,
AlertResponse,
ConnectorMappings,
CasesByAlertId,
@ -52,7 +50,6 @@ import {
CasesMetricsResponse,
CasesBulkGetResponse,
} from '@kbn/cases-plugin/common/api';
import { getCaseUserActionUrl } from '@kbn/cases-plugin/common/api/helpers';
import { SignalHit } from '@kbn/security-solution-plugin/server/lib/detection_engine/signals/types';
import { ActionResult } from '@kbn/actions-plugin/server/types';
import { ESCasesConfigureAttributes } from '@kbn/cases-plugin/server/services/configure/types';
@ -231,7 +228,7 @@ interface CommonSavedObjectAttributes {
const savedObjectCommonAttributes = ['created_at', 'updated_at', 'version', 'id'];
const removeServerGeneratedPropertiesFromObject = <T extends object, K extends keyof T>(
export const removeServerGeneratedPropertiesFromObject = <T extends object, K extends keyof T>(
object: T,
keys: K[]
): Omit<T, K> => {
@ -249,16 +246,6 @@ export const removeServerGeneratedPropertiesFromSavedObject = <
]);
};
export const removeServerGeneratedPropertiesFromUserAction = (
attributes: CaseUserActionResponse
) => {
const keysToRemove: Array<keyof CaseUserActionResponse> = ['action_id', 'created_at'];
return removeServerGeneratedPropertiesFromObject<
CaseUserActionResponse,
typeof keysToRemove[number]
>(attributes, keysToRemove);
};
export const removeServerGeneratedPropertiesFromCase = (
theCase: CaseResponse
): Partial<CaseResponse> => {
@ -602,24 +589,6 @@ export const updateCase = async ({
return cases;
};
export const getCaseUserActions = async ({
supertest,
caseID,
expectedHttpCode = 200,
auth = { user: superUser, space: null },
}: {
supertest: SuperTest.SuperTest<SuperTest.Test>;
caseID: string;
expectedHttpCode?: number;
auth?: { user: User; space: string | null };
}): Promise<CaseUserActionsResponse> => {
const { body: userActions } = await supertest
.get(`${getSpaceUrlPrefix(auth.space)}${getCaseUserActionUrl(caseID)}`)
.auth(auth.user.username, auth.user.password)
.expect(expectedHttpCode);
return userActions;
};
export const deleteComment = async ({
supertest,
caseId,

View file

@ -25,13 +25,13 @@ import {
deleteAllCaseItems,
createCase,
createComment,
getCaseUserActions,
removeServerGeneratedPropertiesFromSavedObject,
bulkCreateAttachments,
updateComment,
getSOFromKibanaIndex,
getReferenceFromEsResponse,
} from '../../../../common/lib/utils';
import { getCaseUserActions } from '../../../../common/lib/user_actions';
// eslint-disable-next-line import/no-default-export
export default ({ getService }: FtrProviderContext): void => {

View file

@ -24,7 +24,6 @@ import {
deleteAllCaseItems,
createCase,
createComment,
getCaseUserActions,
removeServerGeneratedPropertiesFromSavedObject,
getComment,
getSOFromKibanaIndex,
@ -32,6 +31,7 @@ import {
bulkCreateAttachments,
updateComment,
} from '../../../../common/lib/utils';
import { getCaseUserActions } from '../../../../common/lib/user_actions';
// eslint-disable-next-line import/no-default-export
export default ({ getService }: FtrProviderContext): void => {

View file

@ -19,8 +19,8 @@ import {
getComment,
getCase,
superUserSpace1Auth,
getCaseUserActions,
} from '../../../../common/lib/utils';
import { getCaseUserActions } from '../../../../common/lib/user_actions';
import {
secOnly,
secOnlyRead,

View file

@ -36,8 +36,8 @@ import {
createCase,
createComment,
findCases,
getCaseUserActions,
} from '../../../../common/lib/utils';
import { getCaseUserActions } from '../../../../common/lib/user_actions';
import { getPostCaseRequest, postCommentUserReq } from '../../../../common/lib/mock';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';

View file

@ -30,14 +30,16 @@ import {
createCase,
createComment,
updateCase,
getCaseUserActions,
removeServerGeneratedPropertiesFromCase,
removeServerGeneratedPropertiesFromUserAction,
findCases,
superUserSpace1Auth,
delay,
calculateDuration,
} from '../../../../common/lib/utils';
import {
getCaseUserActions,
removeServerGeneratedPropertiesFromUserAction,
} from '../../../../common/lib/user_actions';
import {
createSignalsIndex,
deleteSignalsIndex,

View file

@ -19,9 +19,11 @@ import {
deleteCasesByESQuery,
createCase,
removeServerGeneratedPropertiesFromCase,
removeServerGeneratedPropertiesFromUserAction,
getCaseUserActions,
} from '../../../../common/lib/utils';
import {
getCaseUserActions,
removeServerGeneratedPropertiesFromUserAction,
} from '../../../../common/lib/user_actions';
import {
secOnly,
secOnlyRead,

View file

@ -31,12 +31,14 @@ import {
deleteComments,
createCase,
createComment,
getCaseUserActions,
removeServerGeneratedPropertiesFromUserAction,
removeServerGeneratedPropertiesFromSavedObject,
superUserSpace1Auth,
updateCase,
} from '../../../../common/lib/utils';
import {
getCaseUserActions,
removeServerGeneratedPropertiesFromUserAction,
} from '../../../../common/lib/user_actions';
import {
createSignalsIndex,
deleteSignalsIndex,

View file

@ -30,6 +30,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => {
loadTestFile(require.resolve('./cases/status/get_status'));
loadTestFile(require.resolve('./cases/tags/get_tags'));
loadTestFile(require.resolve('./user_actions/get_all_user_actions'));
loadTestFile(require.resolve('./user_actions/find_user_actions'));
loadTestFile(require.resolve('./configure/get_configure'));
loadTestFile(require.resolve('./configure/patch_configure'));
loadTestFile(require.resolve('./configure/post_configure'));

View file

@ -28,14 +28,16 @@ import {
import {
deleteAllCaseItems,
createCase,
getCaseUserActions,
removeServerGeneratedPropertiesFromUserAction,
removeServerGeneratedPropertiesFromSavedObject,
superUserSpace1Auth,
createCaseAndBulkCreateAttachments,
bulkCreateAttachments,
updateCase,
} from '../../../../common/lib/utils';
import {
getCaseUserActions,
removeServerGeneratedPropertiesFromUserAction,
} from '../../../../common/lib/user_actions';
import {
createSignalsIndex,
deleteSignalsIndex,

View file

@ -0,0 +1,920 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import {
ActionTypes,
CaseResponse,
CaseSeverity,
CaseStatuses,
CommentUserAction,
ConnectorTypes,
FindTypes,
} from '@kbn/cases-plugin/common/api';
import {
globalRead,
noKibanaPrivileges,
obsOnly,
obsOnlyRead,
secOnly,
secOnlyRead,
superUser,
} from '../../../../common/lib/authentication/users';
import { findCaseUserActions, getCaseUserActions } from '../../../../common/lib/user_actions';
import {
getPostCaseRequest,
persistableStateAttachment,
postCommentActionsReq,
postCommentAlertReq,
postCommentUserReq,
postExternalReferenceESReq,
} from '../../../../common/lib/mock';
import {
deleteAllCaseItems,
createCase,
updateCase,
createComment,
bulkCreateAttachments,
} from '../../../../common/lib/utils';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default ({ getService }: FtrProviderContext): void => {
const supertest = getService('supertest');
const es = getService('es');
describe('find_user_actions', () => {
afterEach(async () => {
await deleteAllCaseItems(es);
});
it('returns the id and version fields but not action_id', async () => {
const theCase = await createCase(supertest, getPostCaseRequest());
const allUserActions = await getCaseUserActions({ supertest, caseID: theCase.id });
const response = await findCaseUserActions({
caseID: theCase.id,
supertest,
});
expect(response.userActions.length).to.be(1);
expect(response.userActions[0].id).not.to.be(undefined);
expect(response.userActions[0].id).to.eql(allUserActions[0].action_id);
expect(response.userActions[0].version).not.to.be(undefined);
expect(response.userActions[0]).not.to.have.property('action_id');
expect(response.userActions[0]).not.to.have.property('case_id');
});
describe('default parameters', () => {
it('performs a search using the default parameters when no query params are sent', async () => {
const theCase = await createCase(supertest, getPostCaseRequest());
await createComment({
supertest,
caseId: theCase.id,
params: postCommentUserReq,
});
const response = await findCaseUserActions({
caseID: theCase.id,
supertest,
});
expect(response.userActions.length).to.be(2);
const createCaseUserAction = response.userActions[0];
expect(createCaseUserAction.type).to.eql('create_case');
expect(createCaseUserAction.action).to.eql('create');
const commentUserAction = response.userActions[1];
expect(commentUserAction.type).to.eql('comment');
expect(commentUserAction.action).to.eql('create');
expect(response.page).to.be(1);
expect(response.perPage).to.be(20);
expect(response.total).to.be(2);
});
});
describe('sorting', () => {
it('sorts the results in descending order by the created_at field', async () => {
const theCase = await createCase(supertest, getPostCaseRequest());
await createComment({
supertest,
caseId: theCase.id,
params: postCommentUserReq,
});
const response = await findCaseUserActions({
caseID: theCase.id,
supertest,
options: {
sortOrder: 'desc',
types: [ActionTypes.comment, ActionTypes.create_case],
},
});
expect(response.userActions.length).to.be(2);
const commentUserAction = response.userActions[0];
expect(commentUserAction.type).to.eql('comment');
expect(commentUserAction.action).to.eql('create');
});
});
describe('pagination', () => {
let theCase: CaseResponse;
beforeEach(async () => {
theCase = await createCase(supertest, getPostCaseRequest());
await bulkCreateAttachments({
supertest,
caseId: theCase.id,
params: [postCommentUserReq, postCommentUserReq],
});
});
it('retrieves only 1 user action when perPage is 1', async () => {
const response = await findCaseUserActions({
caseID: theCase.id,
supertest,
options: {
sortOrder: 'asc',
types: [ActionTypes.comment, ActionTypes.create_case],
perPage: 1,
},
});
expect(response.userActions.length).to.be(1);
const commentUserAction = response.userActions[0];
expect(commentUserAction.type).to.eql('create_case');
expect(commentUserAction.action).to.eql('create');
});
it('retrieves 2 user action when perPage is 2 and there are 3 user actions', async () => {
const response = await findCaseUserActions({
caseID: theCase.id,
supertest,
options: {
sortOrder: 'asc',
types: [ActionTypes.comment, ActionTypes.create_case],
perPage: 2,
},
});
expect(response.userActions.length).to.be(2);
expect(response.total).to.be(3);
const createCaseUserAction = response.userActions[0];
expect(createCaseUserAction.type).to.eql('create_case');
expect(createCaseUserAction.action).to.eql('create');
const commentUserAction = response.userActions[1];
expect(commentUserAction.type).to.eql('comment');
expect(commentUserAction.action).to.eql('create');
});
it('retrieves the second page of results', async () => {
const response = await findCaseUserActions({
caseID: theCase.id,
supertest,
options: {
sortOrder: 'asc',
types: [ActionTypes.comment, ActionTypes.create_case],
page: 2,
perPage: 1,
},
});
expect(response.userActions.length).to.be(1);
expect(response.total).to.be(3);
expect(response.userActions[0].type).to.eql('comment');
expect(response.userActions[0].action).to.eql('create');
});
it('retrieves the third page of results', async () => {
const response = await findCaseUserActions({
caseID: theCase.id,
supertest,
options: {
sortOrder: 'asc',
types: [ActionTypes.comment, ActionTypes.create_case],
page: 3,
perPage: 1,
},
});
expect(response.userActions.length).to.be(1);
expect(response.total).to.be(3);
expect(response.userActions[0].type).to.eql('comment');
expect(response.userActions[0].action).to.eql('create');
});
it('retrieves all the results with a perPage larger than the total', async () => {
const response = await findCaseUserActions({
caseID: theCase.id,
supertest,
options: {
sortOrder: 'asc',
types: [ActionTypes.comment, ActionTypes.create_case],
page: 1,
perPage: 10,
},
});
expect(response.userActions.length).to.be(3);
expect(response.total).to.be(3);
expect(response.userActions[0].type).to.eql('create_case');
expect(response.userActions[0].action).to.eql('create');
});
});
describe('filters using the type query parameter', () => {
it('returns a 400 when filtering for an invalid type', async () => {
await findCaseUserActions({
caseID: '123',
supertest,
options: {
sortOrder: 'asc',
// @ts-expect-error using an invalid filter type
types: ['invalid-type'],
},
expectedHttpCode: 400,
});
});
it('returns an empty array when the user action type does not exist', async () => {
const theCase = await createCase(supertest, getPostCaseRequest());
const response = await findCaseUserActions({
caseID: theCase.id,
supertest,
options: {
sortOrder: 'asc',
types: [ActionTypes.comment],
},
});
expect(response.userActions.length).to.be(0);
});
it('retrieves only the comment user actions', async () => {
const theCase = await createCase(supertest, getPostCaseRequest());
await createComment({
supertest,
caseId: theCase.id,
params: postCommentUserReq,
});
const response = await findCaseUserActions({
caseID: theCase.id,
supertest,
options: {
sortOrder: 'asc',
types: [ActionTypes.comment],
},
});
expect(response.userActions.length).to.be(1);
const commentUserAction = response.userActions[0];
expect(commentUserAction.type).to.eql('comment');
expect(commentUserAction.action).to.eql('create');
expect(commentUserAction.payload).to.eql({
comment: postCommentUserReq,
});
});
it('retrieves only the connector user actions', async () => {
const theCase = await createCase(supertest, getPostCaseRequest());
await updateCase({
params: {
cases: [
{
id: theCase.id,
version: theCase.version,
connector: {
id: 'my-jira',
name: 'jira',
type: ConnectorTypes.jira,
fields: {
issueType: 'task',
parent: null,
priority: null,
},
},
},
],
},
supertest,
});
const response = await findCaseUserActions({
caseID: theCase.id,
supertest,
options: {
sortOrder: 'asc',
types: [ActionTypes.connector],
},
});
expect(response.userActions.length).to.be(1);
const updateConnectorUserAction = response.userActions[0];
expect(updateConnectorUserAction.type).to.eql('connector');
expect(updateConnectorUserAction.action).to.eql('update');
expect(updateConnectorUserAction.payload).to.eql({
connector: {
id: 'my-jira',
name: 'jira',
type: ConnectorTypes.jira,
fields: {
issueType: 'task',
parent: null,
priority: null,
},
},
});
});
it('retrieves only the description user actions', async () => {
const newDesc = 'Such a great description';
const theCase = await createCase(supertest, getPostCaseRequest());
await updateCase({
supertest,
params: {
cases: [
{
id: theCase.id,
version: theCase.version,
description: newDesc,
},
],
},
});
const response = await findCaseUserActions({
caseID: theCase.id,
supertest,
options: {
sortOrder: 'asc',
types: [ActionTypes.description],
},
});
expect(response.userActions.length).to.be(1);
const descriptionUserAction = response.userActions[0];
expect(descriptionUserAction.type).to.eql('description');
expect(descriptionUserAction.action).to.eql('update');
expect(descriptionUserAction.payload).to.eql({ description: newDesc });
});
it('retrieves only the tags user actions', async () => {
const theCase = await createCase(supertest, getPostCaseRequest());
await updateCase({
supertest,
params: {
cases: [
{
id: theCase.id,
version: theCase.version,
tags: ['cool', 'neat'],
},
],
},
});
const response = await findCaseUserActions({
caseID: theCase.id,
supertest,
options: {
sortOrder: 'asc',
types: [ActionTypes.tags],
},
});
expect(response.userActions.length).to.be(2);
const addTagsUserAction = response.userActions[0];
const deleteTagsUserAction = response.userActions[1];
expect(addTagsUserAction.type).to.eql('tags');
expect(addTagsUserAction.action).to.eql('add');
expect(addTagsUserAction.payload).to.eql({ tags: ['cool', 'neat'] });
expect(deleteTagsUserAction.type).to.eql('tags');
expect(deleteTagsUserAction.action).to.eql('delete');
expect(deleteTagsUserAction.payload).to.eql({ tags: ['defacement'] });
});
it('retrieves only the title user actions', async () => {
const newTitle = 'Such a great title';
const theCase = await createCase(supertest, getPostCaseRequest());
await updateCase({
supertest,
params: {
cases: [
{
id: theCase.id,
version: theCase.version,
title: newTitle,
},
],
},
});
const response = await findCaseUserActions({
caseID: theCase.id,
supertest,
options: {
sortOrder: 'asc',
types: [ActionTypes.title],
},
});
expect(response.userActions.length).to.be(1);
const descriptionUserAction = response.userActions[0];
expect(descriptionUserAction.type).to.eql('title');
expect(descriptionUserAction.action).to.eql('update');
expect(descriptionUserAction.payload).to.eql({ title: newTitle });
});
it('retrieves only the status user actions', async () => {
const theCase = await createCase(supertest, getPostCaseRequest());
await updateCase({
supertest,
params: {
cases: [
{
id: theCase.id,
version: theCase.version,
status: CaseStatuses.closed,
},
],
},
});
const response = await findCaseUserActions({
caseID: theCase.id,
supertest,
options: {
sortOrder: 'asc',
types: [ActionTypes.status],
},
});
expect(response.userActions.length).to.be(1);
const statusUserAction = response.userActions[0];
expect(statusUserAction.type).to.eql('status');
expect(statusUserAction.action).to.eql('update');
expect(statusUserAction.payload).to.eql({ status: 'closed' });
});
it('retrieves only the settings user actions', async () => {
const theCase = await createCase(supertest, getPostCaseRequest());
await updateCase({
supertest,
params: {
cases: [
{
id: theCase.id,
version: theCase.version,
settings: { syncAlerts: false },
},
],
},
});
const response = await findCaseUserActions({
caseID: theCase.id,
supertest,
options: {
sortOrder: 'asc',
types: [ActionTypes.settings],
},
});
expect(response.userActions.length).to.be(1);
const settingsUserAction = response.userActions[0];
expect(settingsUserAction.type).to.eql('settings');
expect(settingsUserAction.action).to.eql('update');
expect(settingsUserAction.payload).to.eql({ settings: { syncAlerts: false } });
});
it('retrieves only the severity user actions', async () => {
const theCase = await createCase(supertest, getPostCaseRequest());
await updateCase({
supertest,
params: {
cases: [
{
id: theCase.id,
version: theCase.version,
severity: CaseSeverity.HIGH,
},
],
},
});
const response = await findCaseUserActions({
caseID: theCase.id,
supertest,
options: {
sortOrder: 'asc',
types: [ActionTypes.severity],
},
});
expect(response.userActions.length).to.be(1);
const severityUserAction = response.userActions[0];
expect(severityUserAction.type).to.eql('severity');
expect(severityUserAction.action).to.eql('update');
expect(severityUserAction.payload).to.eql({ severity: CaseSeverity.HIGH });
});
it('retrieves only the create_case user actions', async () => {
const theCase = await createCase(supertest, getPostCaseRequest());
const response = await findCaseUserActions({
caseID: theCase.id,
supertest,
options: {
sortOrder: 'asc',
types: [ActionTypes.create_case],
},
});
expect(response.userActions.length).to.be(1);
const createCaseUserAction = response.userActions[0];
expect(createCaseUserAction.type).to.eql('create_case');
expect(createCaseUserAction.action).to.eql('create');
});
it('retrieves any non user comment user actions using the action filter', async () => {
const theCase = await createCase(supertest, getPostCaseRequest());
const updatedCase = await bulkCreateAttachments({
supertest,
caseId: theCase.id,
params: [
postCommentUserReq,
postExternalReferenceESReq,
persistableStateAttachment,
postCommentActionsReq,
],
});
await updateCase({
supertest,
params: {
cases: [
{
id: updatedCase.id,
version: updatedCase.version,
severity: CaseSeverity.HIGH,
},
],
},
});
const response = await findCaseUserActions({
caseID: theCase.id,
supertest,
options: {
sortOrder: 'asc',
types: [FindTypes.action],
},
});
expect(response.userActions.length).to.be(5);
const createCaseUserAction = response.userActions[0];
expect(createCaseUserAction.type).to.eql('create_case');
expect(createCaseUserAction.action).to.eql('create');
const externalRef = response.userActions[1] as CommentUserAction;
expect(externalRef.type).to.eql('comment');
expect(externalRef.payload.comment.type).to.eql('externalReference');
expect(externalRef.action).to.eql('create');
const persistableState = response.userActions[2] as CommentUserAction;
expect(persistableState.type).to.eql('comment');
expect(persistableState.payload.comment.type).to.eql('persistableState');
expect(persistableState.action).to.eql('create');
const actions = response.userActions[3] as CommentUserAction;
expect(actions.type).to.eql('comment');
expect(actions.payload.comment.type).to.eql('actions');
expect(actions.action).to.eql('create');
expect(response.userActions[4].type).to.eql('severity');
expect(response.userActions[4].action).to.eql('update');
});
it('retrieves only alert user actions', async () => {
const theCase = await createCase(supertest, getPostCaseRequest());
await bulkCreateAttachments({
supertest,
caseId: theCase.id,
params: [postCommentUserReq, postCommentAlertReq],
});
const response = await findCaseUserActions({
caseID: theCase.id,
supertest,
options: {
sortOrder: 'asc',
types: [FindTypes.alert],
},
});
expect(response.userActions.length).to.be(1);
const alertUserAction = response.userActions[0] as CommentUserAction;
expect(alertUserAction.type).to.eql('comment');
expect(alertUserAction.action).to.eql('create');
expect(alertUserAction.payload.comment.type).to.eql('alert');
});
it('retrieves only user comment user actions', async () => {
const theCase = await createCase(supertest, getPostCaseRequest());
await bulkCreateAttachments({
supertest,
caseId: theCase.id,
params: [postCommentUserReq, postCommentActionsReq, postCommentAlertReq],
});
const response = await findCaseUserActions({
caseID: theCase.id,
supertest,
options: {
sortOrder: 'asc',
types: [FindTypes.user],
},
});
expect(response.userActions.length).to.be(1);
const userCommentUserAction = response.userActions[0] as CommentUserAction;
expect(userCommentUserAction.type).to.eql('comment');
expect(userCommentUserAction.action).to.eql('create');
expect(userCommentUserAction.payload.comment.type).to.eql('user');
});
it('retrieves attachment user actions', async () => {
const theCase = await createCase(supertest, getPostCaseRequest());
await bulkCreateAttachments({
supertest,
caseId: theCase.id,
params: [
// This one should not show up in the filter for attachments
postCommentUserReq,
postExternalReferenceESReq,
persistableStateAttachment,
// This one should not show up in the filter for attachments
postCommentActionsReq,
// This one should not show up in the filter for attachments
postCommentAlertReq,
],
});
const response = await findCaseUserActions({
caseID: theCase.id,
supertest,
options: {
sortOrder: 'asc',
types: [FindTypes.attachment],
},
});
expect(response.userActions.length).to.be(2);
const externalRefUserAction = response.userActions[0] as CommentUserAction;
expect(externalRefUserAction.type).to.eql('comment');
expect(externalRefUserAction.action).to.eql('create');
expect(externalRefUserAction.payload.comment.type).to.eql('externalReference');
const peristableStateUserAction = response.userActions[1] as CommentUserAction;
expect(peristableStateUserAction.type).to.eql('comment');
expect(peristableStateUserAction.action).to.eql('create');
expect(peristableStateUserAction.payload.comment.type).to.eql('persistableState');
});
describe('filtering on multiple types', () => {
it('retrieves the create_case and comment user actions', async () => {
const theCase = await createCase(supertest, getPostCaseRequest());
await createComment({
supertest,
caseId: theCase.id,
params: postCommentUserReq,
});
const response = await findCaseUserActions({
caseID: theCase.id,
supertest,
options: {
sortOrder: 'asc',
types: [ActionTypes.create_case, ActionTypes.comment],
},
});
expect(response.userActions.length).to.be(2);
const createCaseUserAction = response.userActions[0];
expect(createCaseUserAction.type).to.eql('create_case');
expect(createCaseUserAction.action).to.eql('create');
const commentUserAction = response.userActions[1];
expect(commentUserAction.type).to.eql('comment');
expect(commentUserAction.action).to.eql('create');
});
it('retrieves the create_case user action when there are not valid comment user actions', async () => {
const theCase = await createCase(supertest, getPostCaseRequest());
const response = await findCaseUserActions({
caseID: theCase.id,
supertest,
options: {
sortOrder: 'asc',
types: [ActionTypes.create_case, ActionTypes.comment],
},
});
expect(response.userActions.length).to.be(1);
const createCaseUserAction = response.userActions[0];
expect(createCaseUserAction.type).to.eql('create_case');
expect(createCaseUserAction.action).to.eql('create');
});
});
describe('rbac', () => {
const supertestWithoutAuth = getService('supertestWithoutAuth');
let secCase: CaseResponse;
let obsCase: CaseResponse;
let secCaseSpace2: CaseResponse;
beforeEach(async () => {
[secCase, obsCase, secCaseSpace2] = await Promise.all([
createCase(
supertestWithoutAuth,
getPostCaseRequest({ owner: 'securitySolutionFixture' }),
200,
{
user: secOnly,
space: 'space1',
}
),
createCase(
supertestWithoutAuth,
getPostCaseRequest({ owner: 'observabilityFixture' }),
200,
{
user: obsOnly,
space: 'space1',
}
),
createCase(
supertestWithoutAuth,
getPostCaseRequest({ owner: 'securitySolutionFixture' }),
200,
{
user: superUser,
space: 'space2',
}
),
]);
});
it('should return with the correct status code when executing with various users', async () => {
for (const scenario of [
{
user: globalRead,
id: secCase.id,
space: 'space1',
},
{
user: globalRead,
id: obsCase.id,
space: 'space1',
},
{
user: superUser,
id: secCase.id,
space: 'space1',
},
{
user: superUser,
id: obsCase.id,
space: 'space1',
},
{
user: secOnlyRead,
id: secCase.id,
space: 'space1',
},
{
user: obsOnlyRead,
id: obsCase.id,
space: 'space1',
},
]) {
const res = await findCaseUserActions({
caseID: scenario.id,
supertest: supertestWithoutAuth,
options: {
sortOrder: 'asc',
types: [ActionTypes.create_case],
},
auth: { user: scenario.user, space: scenario.space },
});
expect(res.userActions.length).to.be(1);
}
});
it('should fail to find user actions for a case that the user is not authorized for', async () => {
for (const scenario of [
{
user: secOnlyRead,
id: obsCase.id,
space: 'space1',
expectedCode: 403,
},
{
user: obsOnlyRead,
id: secCase.id,
space: 'space1',
expectedCode: 403,
},
{
user: noKibanaPrivileges,
id: secCase.id,
space: 'space1',
expectedCode: 403,
},
{
user: secOnlyRead,
id: secCaseSpace2.id,
space: 'space2',
expectedCode: 403,
},
]) {
await findCaseUserActions({
caseID: scenario.id,
supertest: supertestWithoutAuth,
expectedHttpCode: scenario.expectedCode,
options: {
sortOrder: 'asc',
types: [ActionTypes.create_case],
},
auth: { user: scenario.user, space: scenario.space },
});
}
});
});
});
});
};

View file

@ -22,7 +22,6 @@ import {
deleteAllCaseItems,
createCase,
updateCase,
getCaseUserActions,
superUserSpace1Auth,
deleteCases,
createComment,
@ -30,6 +29,7 @@ import {
deleteComment,
extractWarningValueFromWarningHeader,
} from '../../../../common/lib/utils';
import { getCaseUserActions } from '../../../../common/lib/user_actions';
import {
globalRead,
noKibanaPrivileges,
@ -51,6 +51,23 @@ export default ({ getService }: FtrProviderContext): void => {
await deleteAllCaseItems(es);
});
it('populates the action_id', async () => {
const theCase = await createCase(supertest, postCaseReq);
const userActions = await getCaseUserActions({ supertest, caseID: theCase.id });
expect(userActions.length).to.be(1);
expect(userActions[0].action_id).not.to.be(undefined);
expect(userActions[0]).not.to.have.property('id');
});
it('populates the case_id', async () => {
const theCase = await createCase(supertest, postCaseReq);
const userActions = await getCaseUserActions({ supertest, caseID: theCase.id });
expect(userActions.length).to.be(1);
expect(userActions[0].case_id).not.to.be(undefined);
});
it('creates a create case user action when a case is created', async () => {
const theCase = await createCase(supertest, postCaseReq);
const userActions = await getCaseUserActions({ supertest, caseID: theCase.id });

View file

@ -9,7 +9,8 @@ import expect from '@kbn/expect';
import { CASES_URL, SECURITY_SOLUTION_OWNER } from '@kbn/cases-plugin/common/constants';
import { ActionTypes, CaseUserActionsResponse, CommentType } from '@kbn/cases-plugin/common/api';
import { FtrProviderContext } from '../../../../../common/ftr_provider_context';
import { deleteAllCaseItems, getCaseUserActions } from '../../../../common/lib/utils';
import { deleteAllCaseItems } from '../../../../common/lib/utils';
import { getCaseUserActions } from '../../../../common/lib/user_actions';
// eslint-disable-next-line import/no-default-export
export default function createGetTests({ getService }: FtrProviderContext) {

View file

@ -0,0 +1,120 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import http from 'http';
import expect from '@kbn/expect';
import { ActionTypes } from '@kbn/cases-plugin/common/api';
import { getPostCaseRequest } from '../../../../../common/lib/mock';
import {
deleteAllCaseItems,
createCase,
updateCase,
pushCase,
} from '../../../../../common/lib/utils';
import {
createCaseWithConnector,
getServiceNowSimulationServer,
} from '../../../../../common/lib/connectors';
import { findCaseUserActions } from '../../../../../common/lib/user_actions';
import { ObjectRemover as ActionsRemover } from '../../../../../../alerting_api_integration/common/lib';
import { FtrProviderContext } from '../../../../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default ({ getService }: FtrProviderContext): void => {
const supertest = getService('supertest');
const es = getService('es');
const actionsRemover = new ActionsRemover(supertest);
describe('find_user_actions', () => {
let serviceNowSimulatorURL: string = '';
let serviceNowServer: http.Server;
before(async () => {
const { url, server } = await getServiceNowSimulationServer();
serviceNowSimulatorURL = url;
serviceNowServer = server;
});
afterEach(async () => {
await deleteAllCaseItems(es);
await actionsRemover.removeAll();
});
after(async () => {
serviceNowServer.close();
});
describe('filters using the type query parameter', () => {
it('retrieves only the assignees user actions', async () => {
const theCase = await createCase(supertest, getPostCaseRequest());
await updateCase({
params: {
cases: [
{
id: theCase.id,
version: theCase.version,
assignees: [
{
uid: '123',
},
],
},
],
},
supertest,
});
const response = await findCaseUserActions({
caseID: theCase.id,
supertest,
options: {
sortOrder: 'asc',
types: [ActionTypes.assignees],
},
});
expect(response.userActions.length).to.be(1);
const addAssigneesUserAction = response.userActions[0];
expect(addAssigneesUserAction.type).to.eql('assignees');
expect(addAssigneesUserAction.action).to.eql('add');
expect(addAssigneesUserAction.payload).to.eql({ assignees: [{ uid: '123' }] });
});
it('retrieves only the pushed user actions', async () => {
const { postedCase, connector } = await createCaseWithConnector({
supertest,
serviceNowSimulatorURL,
actionsRemover,
});
const theCase = await pushCase({
supertest,
caseId: postedCase.id,
connectorId: connector.id,
});
const response = await findCaseUserActions({
caseID: theCase.id,
supertest,
options: {
sortOrder: 'asc',
types: [ActionTypes.pushed],
},
});
expect(response.userActions.length).to.be(1);
const pushedUserAction = response.userActions[0];
expect(pushedUserAction.type).to.eql('pushed');
expect(pushedUserAction.action).to.eql('push_to_service');
});
});
});
};

View file

@ -17,11 +17,11 @@ import {
deleteCasesUserActions,
deleteComments,
deleteConfiguration,
getCaseUserActions,
pushCase,
updateCase,
updateConfiguration,
} from '../../../../../common/lib/utils';
import { getCaseUserActions } from '../../../../../common/lib/user_actions';
import {
createCaseWithConnector,
getServiceNowSimulationServer,

View file

@ -29,6 +29,7 @@ export default ({ loadTestFile, getService }: FtrProviderContext): void => {
// Trial
loadTestFile(require.resolve('./cases/push_case'));
loadTestFile(require.resolve('./cases/user_actions/get_all_user_actions'));
loadTestFile(require.resolve('./cases/user_actions/find_user_actions'));
loadTestFile(require.resolve('./cases/assignees'));
loadTestFile(require.resolve('./cases/find_cases'));
loadTestFile(require.resolve('./configure'));

View file

@ -26,10 +26,10 @@ import {
createCase,
createComment,
deleteAllCaseItems,
getCaseUserActions,
pushCase,
updateCase,
} from '../../../../common/lib/utils';
import { getCaseUserActions } from '../../../../common/lib/user_actions';
import { getPostCaseRequest, postCommentUserReq } from '../../../../common/lib/mock';
import {
createCaseWithConnector,

View file

@ -21,8 +21,8 @@ import {
updateComment,
getConfigurationRequest,
updateConfiguration,
getCaseUserActions,
} from '../../../../common/lib/utils';
import { getCaseUserActions } from '../../../../common/lib/user_actions';
import { getPostCaseRequest, postCommentUserReq } from '../../../../common/lib/mock';
// eslint-disable-next-line import/no-default-export

View file

@ -9,12 +9,8 @@ import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
import { getPostCaseRequest } from '../../../../common/lib/mock';
import {
deleteAllCaseItems,
createCase,
getCaseUserActions,
getAuthWithSuperUser,
} from '../../../../common/lib/utils';
import { deleteAllCaseItems, createCase, getAuthWithSuperUser } from '../../../../common/lib/utils';
import { getCaseUserActions } from '../../../../common/lib/user_actions';
// eslint-disable-next-line import/no-default-export
export default ({ getService }: FtrProviderContext): void => {