[ResponseOps][Cases] Add assignee field and filtering (#137532)

* Refactoring services, auth

* Adding suggest api and tests

* Working integration tests

* Switching suggest api tags

* Adding assignees field

* Adding tests for size and owner

* Adding assignee integration tests

* Starting user actions changes

* Using lodash for array comparison logic and tests

* Fixing type error

* Adding assignees user action

* [ResponseOps][Cases] Refactoring client args and authentication (#137345)

* Refactoring services, auth

* Fixing type errors

* Adding assignees migration and tests

* Fixing types and added more tests

* Fixing cypress test

* Fixing test

* Adding migration for assignees field and tests

* Adding comments and a few more tests

* Updating comments and spelling

* Addressing feedback

* Removing optional owners

* Forgot rest of files

* Adding default empty array for user actions

* Fixing test error

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Jonathan Buttner 2022-08-04 15:25:36 -04:00 committed by GitHub
parent 0f314a60bd
commit aa7d8e8ab7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
56 changed files with 1688 additions and 85 deletions

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as rt from 'io-ts';
import { CaseUserProfileRt } from './user_profiles';
export const CaseAssigneesRt = rt.array(CaseUserProfileRt);
export type CaseAssignees = rt.TypeOf<typeof CaseAssigneesRt>;

View file

@ -12,6 +12,7 @@ import { UserRT } from '../user';
import { CommentResponseRt } from './comment'; import { CommentResponseRt } from './comment';
import { CasesStatusResponseRt, CaseStatusRt } from './status'; import { CasesStatusResponseRt, CaseStatusRt } from './status';
import { CaseConnectorRt } from '../connectors'; import { CaseConnectorRt } from '../connectors';
import { CaseAssigneesRt } from './assignee';
const BucketsAggs = rt.array( const BucketsAggs = rt.array(
rt.type({ rt.type({
@ -86,6 +87,10 @@ const CaseBasicRt = rt.type({
* The severity of the case * The severity of the case
*/ */
severity: CaseSeverityRt, severity: CaseSeverityRt,
/**
* The users assigned to this case
*/
assignees: CaseAssigneesRt,
}); });
/** /**
@ -153,6 +158,10 @@ export const CasePostRequestRt = rt.intersection([
owner: rt.string, owner: rt.string,
}), }),
rt.partial({ rt.partial({
/**
* The users assigned to the case
*/
assignees: CaseAssigneesRt,
/** /**
* The severity of the case. The severity is * The severity of the case. The severity is
* default it to "low" if not provided. * default it to "low" if not provided.
@ -174,6 +183,10 @@ export const CasesFindRequestRt = rt.partial({
* The severity of the case * The severity of the case
*/ */
severity: CaseSeverityRt, severity: CaseSeverityRt,
/**
* The uids of the user profiles to filter by
*/
assignees: rt.union([rt.array(rt.string), rt.string]),
/** /**
* The reporters to filter by * The reporters to filter by
*/ */

View file

@ -13,3 +13,4 @@ export * from './user_actions';
export * from './constants'; export * from './constants';
export * from './alerts'; export * from './alerts';
export * from './user_profiles'; export * from './user_profiles';
export * from './assignee';

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as rt from 'io-ts';
export const SuggestUserProfilesRequestRt = rt.intersection([
rt.type({
name: rt.string,
owners: rt.array(rt.string),
}),
rt.partial({ size: rt.number }),
]);
export type SuggestUserProfilesRequest = rt.TypeOf<typeof SuggestUserProfilesRequestRt>;

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as rt from 'io-ts';
import { CaseAssigneesRt } from '../assignee';
import { ActionTypes, UserActionWithAttributes } from './common';
export const AssigneesUserActionPayloadRt = rt.type({ assignees: CaseAssigneesRt });
export const AssigneesUserActionRt = rt.type({
type: rt.literal(ActionTypes.assignees),
payload: AssigneesUserActionPayloadRt,
});
export type AssigneesUserAction = UserActionWithAttributes<rt.TypeOf<typeof AssigneesUserActionRt>>;

View file

@ -9,6 +9,7 @@ import * as rt from 'io-ts';
import { UserRT } from '../../user'; import { UserRT } from '../../user';
export const ActionTypes = { export const ActionTypes = {
assignees: 'assignees',
comment: 'comment', comment: 'comment',
connector: 'connector', connector: 'connector',
description: 'description', description: 'description',
@ -22,6 +23,9 @@ export const ActionTypes = {
delete_case: 'delete_case', delete_case: 'delete_case',
} as const; } as const;
export type ActionTypeKeys = keyof typeof ActionTypes;
export type ActionTypeValues = typeof ActionTypes[ActionTypeKeys];
export const Actions = { export const Actions = {
add: 'add', add: 'add',
create: 'create', create: 'create',
@ -30,6 +34,9 @@ export const Actions = {
push_to_service: 'push_to_service', push_to_service: 'push_to_service',
} as const; } as const;
export type ActionOperationKeys = keyof typeof Actions;
export type ActionOperationValues = typeof Actions[ActionOperationKeys];
/* To the next developer, if you add/removed fields here /* To the next developer, if you add/removed fields here
* make sure to check this file (x-pack/plugins/cases/server/services/user_actions/helpers.ts) too * make sure to check this file (x-pack/plugins/cases/server/services/user_actions/helpers.ts) too
*/ */

View file

@ -6,6 +6,7 @@
*/ */
import * as rt from 'io-ts'; import * as rt from 'io-ts';
import { AssigneesUserActionPayloadRt } from './assignees';
import { ActionTypes, UserActionWithAttributes } from './common'; import { ActionTypes, UserActionWithAttributes } from './common';
import { import {
ConnectorUserActionPayloadRt, ConnectorUserActionPayloadRt,
@ -21,6 +22,7 @@ export const CommonFieldsRt = rt.type({
}); });
const CommonPayloadAttributesRt = rt.type({ const CommonPayloadAttributesRt = rt.type({
assignees: AssigneesUserActionPayloadRt.props.assignees,
description: DescriptionUserActionPayloadRt.props.description, description: DescriptionUserActionPayloadRt.props.description,
status: rt.string, status: rt.string,
severity: rt.string, severity: rt.string,

View file

@ -24,6 +24,7 @@ import { SettingsUserActionRt } from './settings';
import { StatusUserActionRt } from './status'; import { StatusUserActionRt } from './status';
import { DeleteCaseUserActionRt } from './delete_case'; import { DeleteCaseUserActionRt } from './delete_case';
import { SeverityUserActionRt } from './severity'; import { SeverityUserActionRt } from './severity';
import { AssigneesUserActionRt } from './assignees';
export * from './common'; export * from './common';
export * from './comment'; export * from './comment';
@ -36,6 +37,7 @@ export * from './settings';
export * from './status'; export * from './status';
export * from './tags'; export * from './tags';
export * from './title'; export * from './title';
export * from './assignees';
const CommonUserActionsRt = rt.union([ const CommonUserActionsRt = rt.union([
DescriptionUserActionRt, DescriptionUserActionRt,
@ -45,6 +47,7 @@ const CommonUserActionsRt = rt.union([
SettingsUserActionRt, SettingsUserActionRt,
StatusUserActionRt, StatusUserActionRt,
SeverityUserActionRt, SeverityUserActionRt,
AssigneesUserActionRt,
]); ]);
export const UserActionsRt = rt.union([ export const UserActionsRt = rt.union([

View file

@ -16,3 +16,9 @@ export const SuggestUserProfilesRequestRt = rt.intersection([
]); ]);
export type SuggestUserProfilesRequest = rt.TypeOf<typeof SuggestUserProfilesRequestRt>; export type SuggestUserProfilesRequest = rt.TypeOf<typeof SuggestUserProfilesRequestRt>;
export const CaseUserProfileRt = rt.type({
uid: rt.string,
});
export type CaseUserProfile = rt.TypeOf<typeof CaseUserProfileRt>;

View file

@ -581,6 +581,7 @@ describe('AllCasesListGeneric', () => {
wrapper.find('[data-test-subj="cases-table-row-select-1"]').first().simulate('click'); wrapper.find('[data-test-subj="cases-table-row-select-1"]').first().simulate('click');
await waitFor(() => { await waitFor(() => {
expect(onRowClick).toHaveBeenCalledWith({ expect(onRowClick).toHaveBeenCalledWith({
assignees: [],
closedAt: null, closedAt: null,
closedBy: null, closedBy: null,
comments: [], comments: [],

View file

@ -33,6 +33,7 @@ const initialCaseValue: FormProps = {
fields: null, fields: null,
syncAlerts: true, syncAlerts: true,
selectedOwner: null, selectedOwner: null,
assignees: [],
}; };
interface Props { interface Props {

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { UserActionBuilder } from './types';
export const createAssigneesUserActionBuilder: UserActionBuilder = ({
userAction,
handleOutlineComment,
}) => ({
build: () => {
return [];
},
});

View file

@ -5,6 +5,7 @@
* 2.0. * 2.0.
*/ */
import { createAssigneesUserActionBuilder } from './assignees';
import { createCommentUserActionBuilder } from './comment/comment'; import { createCommentUserActionBuilder } from './comment/comment';
import { createConnectorUserActionBuilder } from './connector'; import { createConnectorUserActionBuilder } from './connector';
import { createDescriptionUserActionBuilder } from './description'; import { createDescriptionUserActionBuilder } from './description';
@ -26,4 +27,5 @@ export const builderMap: UserActionBuilderMap = {
comment: createCommentUserActionBuilder, comment: createCommentUserActionBuilder,
description: createDescriptionUserActionBuilder, description: createDescriptionUserActionBuilder,
settings: createSettingsUserActionBuilder, settings: createSettingsUserActionBuilder,
assignees: createAssigneesUserActionBuilder,
}; };

View file

@ -228,6 +228,7 @@ export const basicCase: Case = {
settings: { settings: {
syncAlerts: true, syncAlerts: true,
}, },
assignees: [],
}; };
export const caseWithAlerts = { export const caseWithAlerts = {
@ -329,6 +330,7 @@ export const mockCase: Case = {
settings: { settings: {
syncAlerts: true, syncAlerts: true,
}, },
assignees: [],
}; };
export const basicCasePost: Case = { export const basicCasePost: Case = {
@ -631,6 +633,7 @@ export const getUserAction = (
tags: ['a tag'], tags: ['a tag'],
settings: { syncAlerts: true }, settings: { syncAlerts: true },
owner: SECURITY_SOLUTION_OWNER, owner: SECURITY_SOLUTION_OWNER,
assignees: [],
}, },
...overrides, ...overrides,
}; };

View file

@ -86,7 +86,11 @@ export const create = async (
unsecuredSavedObjectsClient, unsecuredSavedObjectsClient,
caseId: newCase.id, caseId: newCase.id,
user, user,
payload: { ...query, severity: query.severity ?? CaseSeverity.LOW }, payload: {
...query,
severity: query.severity ?? CaseSeverity.LOW,
assignees: query.assignees ?? [],
},
owner: newCase.attributes.owner, owner: newCase.attributes.owner,
}); });

View file

@ -62,6 +62,7 @@ export const find = async (
owner: queryParams.owner, owner: queryParams.owner,
from: queryParams.from, from: queryParams.from,
to: queryParams.to, to: queryParams.to,
assignees: queryParams.assignees,
}; };
const statusStatsOptions = constructQueryOptions({ const statusStatsOptions = constructQueryOptions({

View file

@ -242,6 +242,7 @@ export const userActions: CaseUserActionsResponse = [
status: 'open', status: 'open',
severity: 'low', severity: 'low',
owner: SECURITY_SOLUTION_OWNER, owner: SECURITY_SOLUTION_OWNER,
assignees: [],
}, },
action_id: 'fd830c60-6646-11eb-a291-51bf6b175a53', action_id: 'fd830c60-6646-11eb-a291-51bf6b175a53',
case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53', case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53',

View file

@ -57,4 +57,5 @@ export interface ConstructQueryParams {
authorizationFilter?: KueryNode; authorizationFilter?: KueryNode;
from?: string; from?: string;
to?: string; to?: string;
assignees?: string | string[];
} }

View file

@ -5,7 +5,13 @@
* 2.0. * 2.0.
*/ */
import { arraysDifference, buildRangeFilter, constructQueryOptions, sortToSnake } from './utils'; import {
arraysDifference,
buildNestedFilter,
buildRangeFilter,
constructQueryOptions,
sortToSnake,
} from './utils';
import { toElasticsearchQuery } from '@kbn/es-query'; import { toElasticsearchQuery } from '@kbn/es-query';
import { CaseStatuses } from '../../common'; import { CaseStatuses } from '../../common';
import { CaseSeverity } from '../../common/api'; import { CaseSeverity } from '../../common/api';
@ -490,6 +496,163 @@ describe('utils', () => {
}); });
}); });
describe('buildNestedFilter', () => {
it('returns undefined if filters is undefined', () => {
expect(buildNestedFilter({ field: '', nestedField: '', operator: 'or' })).toBeUndefined();
});
it('returns undefined when the filters array is empty', () => {
expect(
buildNestedFilter({ filters: [], field: '', nestedField: '', operator: 'or' })
).toBeUndefined();
});
it('returns a KueryNode for a single filter', () => {
expect(
toElasticsearchQuery(
buildNestedFilter({
filters: ['hello'],
field: 'uid',
nestedField: 'nestedField',
operator: 'or',
})!
)
).toMatchInlineSnapshot(`
Object {
"nested": Object {
"path": "cases.attributes.nestedField",
"query": Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match": Object {
"cases.attributes.nestedField.uid": "hello",
},
},
],
},
},
"score_mode": "none",
},
}
`);
});
it("returns a KueryNode for multiple filters or'd together", () => {
expect(
toElasticsearchQuery(
buildNestedFilter({
filters: ['uid1', 'uid2'],
field: 'uid',
nestedField: 'nestedField',
operator: 'or',
})!
)
).toMatchInlineSnapshot(`
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"nested": Object {
"path": "cases.attributes.nestedField",
"query": Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match": Object {
"cases.attributes.nestedField.uid": "uid1",
},
},
],
},
},
"score_mode": "none",
},
},
Object {
"nested": Object {
"path": "cases.attributes.nestedField",
"query": Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match": Object {
"cases.attributes.nestedField.uid": "uid2",
},
},
],
},
},
"score_mode": "none",
},
},
],
},
}
`);
});
it("returns a KueryNode for multiple filters and'ed together", () => {
expect(
toElasticsearchQuery(
buildNestedFilter({
filters: ['uid1', 'uid2'],
field: 'uid',
nestedField: 'nestedField',
operator: 'and',
})!
)
).toMatchInlineSnapshot(`
Object {
"bool": Object {
"filter": Array [
Object {
"nested": Object {
"path": "cases.attributes.nestedField",
"query": Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match": Object {
"cases.attributes.nestedField.uid": "uid1",
},
},
],
},
},
"score_mode": "none",
},
},
Object {
"nested": Object {
"path": "cases.attributes.nestedField",
"query": Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match": Object {
"cases.attributes.nestedField.uid": "uid2",
},
},
],
},
},
"score_mode": "none",
},
},
],
},
}
`);
});
});
describe('arraysDifference', () => { describe('arraysDifference', () => {
it('returns null if originalValue is null', () => { it('returns null if originalValue is null', () => {
expect(arraysDifference(null, [])).toBeNull(); expect(arraysDifference(null, [])).toBeNull();

View file

@ -177,6 +177,10 @@ interface FilterField {
type?: string; type?: string;
} }
interface NestedFilterField extends FilterField {
nestedField: string;
}
export const buildFilter = ({ export const buildFilter = ({
filters, filters,
field, field,
@ -194,7 +198,48 @@ export const buildFilter = ({
} }
return nodeBuilder[operator]( return nodeBuilder[operator](
filtersAsArray.map((filter) => nodeBuilder.is(`${type}.attributes.${field}`, filter)) filtersAsArray.map((filter) =>
nodeBuilder.is(`${escapeKuery(type)}.attributes.${escapeKuery(field)}`, escapeKuery(filter))
)
);
};
/**
* Creates a KueryNode filter for the Saved Object find API's filter field. This handles constructing a filter for
* a nested field.
*
* @param filters is a string or array of strings that defines the values to search for
* @param field is the location to search for
* @param nestedField is the field in the saved object that has a type of 'nested'
* @param operator whether to 'or'/'and' the created filters together
* @type the type of saved object being searched
* @returns a constructed KueryNode representing the filter or undefined if one could not be built
*/
export const buildNestedFilter = ({
filters,
field,
nestedField,
operator,
type = CASE_SAVED_OBJECT,
}: NestedFilterField): KueryNode | undefined => {
if (filters === undefined) {
return;
}
const filtersAsArray = Array.isArray(filters) ? filters : [filters];
if (filtersAsArray.length === 0) {
return;
}
return nodeBuilder[operator](
filtersAsArray.map((filter) =>
fromKueryExpression(
`${escapeKuery(type)}.attributes.${escapeKuery(nestedField)}:{ ${escapeKuery(
field
)}: ${escapeKuery(filter)} }`
)
)
); );
}; };
@ -284,6 +329,7 @@ export const constructQueryOptions = ({
authorizationFilter, authorizationFilter,
from, from,
to, to,
assignees,
}: ConstructQueryParams): SavedObjectFindOptionsKueryNode => { }: ConstructQueryParams): SavedObjectFindOptionsKueryNode => {
const tagsFilter = buildFilter({ filters: tags, field: 'tags', operator: 'or' }); const tagsFilter = buildFilter({ filters: tags, field: 'tags', operator: 'or' });
const reportersFilter = buildFilter({ const reportersFilter = buildFilter({
@ -297,6 +343,11 @@ export const constructQueryOptions = ({
const statusFilter = status != null ? addStatusFilter({ status }) : undefined; const statusFilter = status != null ? addStatusFilter({ status }) : undefined;
const severityFilter = severity != null ? addSeverityFilter({ severity }) : undefined; const severityFilter = severity != null ? addSeverityFilter({ severity }) : undefined;
const rangeFilter = buildRangeFilter({ from, to }); const rangeFilter = buildRangeFilter({ from, to });
const assigneesFilter = buildFilter({
filters: assignees,
field: 'assignees.uid',
operator: 'or',
});
const filters = combineFilters([ const filters = combineFilters([
statusFilter, statusFilter,
@ -305,6 +356,7 @@ export const constructQueryOptions = ({
reportersFilter, reportersFilter,
rangeFilter, rangeFilter,
ownerFilter, ownerFilter,
assigneesFilter,
]); ]);
return { return {

View file

@ -103,6 +103,7 @@ describe('common utils', () => {
expect(res).toMatchInlineSnapshot(` expect(res).toMatchInlineSnapshot(`
Object { Object {
"assignees": Array [],
"closed_at": null, "closed_at": null,
"closed_by": null, "closed_by": null,
"connector": Object { "connector": Object {
@ -155,6 +156,7 @@ describe('common utils', () => {
expect(res).toMatchInlineSnapshot(` expect(res).toMatchInlineSnapshot(`
Object { Object {
"assignees": Array [],
"closed_at": null, "closed_at": null,
"closed_by": null, "closed_by": null,
"connector": Object { "connector": Object {
@ -192,6 +194,63 @@ describe('common utils', () => {
} }
`); `);
}); });
it('transform correctly with assignees provided', () => {
const myCase = {
newCase: { ...newCase, connector, assignees: [{ uid: '1' }] },
user: {
email: 'elastic@elastic.co',
full_name: 'Elastic',
username: 'elastic',
},
};
const res = transformNewCase(myCase);
expect(res).toMatchInlineSnapshot(`
Object {
"assignees": Array [
Object {
"uid": "1",
},
],
"closed_at": null,
"closed_by": null,
"connector": Object {
"fields": Object {
"issueType": "Task",
"parent": null,
"priority": "High",
},
"id": "123",
"name": "My connector",
"type": ".jira",
},
"created_at": "2020-04-09T09:43:51.778Z",
"created_by": Object {
"email": "elastic@elastic.co",
"full_name": "Elastic",
"username": "elastic",
},
"description": "A description",
"duration": null,
"external_service": null,
"owner": "securitySolution",
"settings": Object {
"syncAlerts": true,
},
"severity": "low",
"status": "open",
"tags": Array [
"new",
"case",
],
"title": "My new case",
"updated_at": null,
"updated_by": null,
}
`);
});
}); });
describe('transformCases', () => { describe('transformCases', () => {
@ -214,6 +273,7 @@ describe('common utils', () => {
Object { Object {
"cases": Array [ "cases": Array [
Object { Object {
"assignees": Array [],
"closed_at": null, "closed_at": null,
"closed_by": null, "closed_by": null,
"comments": Array [], "comments": Array [],
@ -254,6 +314,7 @@ describe('common utils', () => {
"version": "WzAsMV0=", "version": "WzAsMV0=",
}, },
Object { Object {
"assignees": Array [],
"closed_at": null, "closed_at": null,
"closed_by": null, "closed_by": null,
"comments": Array [], "comments": Array [],
@ -294,6 +355,7 @@ describe('common utils', () => {
"version": "WzQsMV0=", "version": "WzQsMV0=",
}, },
Object { Object {
"assignees": Array [],
"closed_at": null, "closed_at": null,
"closed_by": null, "closed_by": null,
"comments": Array [], "comments": Array [],
@ -338,6 +400,7 @@ describe('common utils', () => {
"version": "WzUsMV0=", "version": "WzUsMV0=",
}, },
Object { Object {
"assignees": Array [],
"closed_at": "2019-11-25T22:32:17.947Z", "closed_at": "2019-11-25T22:32:17.947Z",
"closed_by": Object { "closed_by": Object {
"email": "testemail@elastic.co", "email": "testemail@elastic.co",
@ -407,6 +470,7 @@ describe('common utils', () => {
expect(res).toMatchInlineSnapshot(` expect(res).toMatchInlineSnapshot(`
Object { Object {
"assignees": Array [],
"closed_at": null, "closed_at": null,
"closed_by": null, "closed_by": null,
"comments": Array [], "comments": Array [],
@ -463,6 +527,7 @@ describe('common utils', () => {
expect(res).toMatchInlineSnapshot(` expect(res).toMatchInlineSnapshot(`
Object { Object {
"assignees": Array [],
"closed_at": null, "closed_at": null,
"closed_by": null, "closed_by": null,
"comments": Array [], "comments": Array [],
@ -520,6 +585,7 @@ describe('common utils', () => {
expect(res).toMatchInlineSnapshot(` expect(res).toMatchInlineSnapshot(`
Object { Object {
"assignees": Array [],
"closed_at": null, "closed_at": null,
"closed_by": null, "closed_by": null,
"comments": Array [ "comments": Array [
@ -600,6 +666,7 @@ describe('common utils', () => {
expect(res).toMatchInlineSnapshot(` expect(res).toMatchInlineSnapshot(`
Object { Object {
"assignees": Array [],
"closed_at": null, "closed_at": null,
"closed_by": null, "closed_by": null,
"comments": Array [], "comments": Array [],

View file

@ -69,6 +69,7 @@ export const transformNewCase = ({
status: CaseStatuses.open, status: CaseStatuses.open,
updated_at: null, updated_at: null,
updated_by: null, updated_by: null,
assignees: newCase.assignees ?? [],
}); });
export const transformCases = ({ export const transformCases = ({

View file

@ -52,6 +52,7 @@ export const mockCases: Array<SavedObject<CaseAttributes>> = [
syncAlerts: true, syncAlerts: true,
}, },
owner: SECURITY_SOLUTION_OWNER, owner: SECURITY_SOLUTION_OWNER,
assignees: [],
}, },
references: [], references: [],
updated_at: '2019-11-25T21:54:48.952Z', updated_at: '2019-11-25T21:54:48.952Z',
@ -92,6 +93,7 @@ export const mockCases: Array<SavedObject<CaseAttributes>> = [
syncAlerts: true, syncAlerts: true,
}, },
owner: SECURITY_SOLUTION_OWNER, owner: SECURITY_SOLUTION_OWNER,
assignees: [],
}, },
references: [], references: [],
updated_at: '2019-11-25T22:32:00.900Z', updated_at: '2019-11-25T22:32:00.900Z',
@ -132,6 +134,7 @@ export const mockCases: Array<SavedObject<CaseAttributes>> = [
syncAlerts: true, syncAlerts: true,
}, },
owner: SECURITY_SOLUTION_OWNER, owner: SECURITY_SOLUTION_OWNER,
assignees: [],
}, },
references: [], references: [],
updated_at: '2019-11-25T22:32:17.947Z', updated_at: '2019-11-25T22:32:17.947Z',
@ -176,6 +179,7 @@ export const mockCases: Array<SavedObject<CaseAttributes>> = [
syncAlerts: true, syncAlerts: true,
}, },
owner: SECURITY_SOLUTION_OWNER, owner: SECURITY_SOLUTION_OWNER,
assignees: [],
}, },
references: [], references: [],
updated_at: '2019-11-25T22:32:17.947Z', updated_at: '2019-11-25T22:32:17.947Z',

View file

@ -27,6 +27,13 @@ export const createCaseSavedObjectType = (
convertToMultiNamespaceTypeVersion: '8.0.0', convertToMultiNamespaceTypeVersion: '8.0.0',
mappings: { mappings: {
properties: { properties: {
assignees: {
properties: {
uid: {
type: 'keyword',
},
},
},
closed_at: { closed_at: {
type: 'date', type: 'date',
}, },

View file

@ -16,7 +16,13 @@ import {
import { CASE_SAVED_OBJECT } from '../../../common/constants'; import { CASE_SAVED_OBJECT } from '../../../common/constants';
import { getNoneCaseConnector } from '../../common/utils'; import { getNoneCaseConnector } from '../../common/utils';
import { createExternalService, ESCaseConnectorWithId } from '../../services/test_utils'; import { createExternalService, ESCaseConnectorWithId } from '../../services/test_utils';
import { addDuration, addSeverity, caseConnectorIdMigration, removeCaseType } from './cases'; import {
addAssignees,
addDuration,
addSeverity,
caseConnectorIdMigration,
removeCaseType,
} from './cases';
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
const create_7_14_0_case = ({ const create_7_14_0_case = ({
@ -538,4 +544,41 @@ describe('case migrations', () => {
}); });
}); });
}); });
describe('addAssignees', () => {
it('adds the assignees field correctly when none is present', () => {
const doc = {
id: '123',
attributes: {},
type: 'abc',
references: [],
} as unknown as SavedObjectSanitizedDoc<CaseAttributes>;
expect(addAssignees(doc)).toEqual({
...doc,
attributes: {
...doc.attributes,
assignees: [],
},
});
});
it('keeps the existing assignees value if the field already exists', () => {
const assignees = [{ uid: '1' }];
const doc = {
id: '123',
attributes: {
assignees,
},
type: 'abc',
references: [],
} as unknown as SavedObjectSanitizedDoc<CaseAttributes>;
expect(addAssignees(doc)).toEqual({
...doc,
attributes: {
...doc.attributes,
assignees,
},
});
});
});
}); });

View file

@ -122,6 +122,13 @@ export const addSeverity = (
return { ...doc, attributes: { ...doc.attributes, severity }, references: doc.references ?? [] }; return { ...doc, attributes: { ...doc.attributes, severity }, references: doc.references ?? [] };
}; };
export const addAssignees = (
doc: SavedObjectUnsanitizedDoc<CaseAttributes>
): SavedObjectSanitizedDoc<CaseAttributes> => {
const assignees = doc.attributes.assignees ?? [];
return { ...doc, attributes: { ...doc.attributes, assignees }, references: doc.references ?? [] };
};
export const caseMigrations = { export const caseMigrations = {
'7.10.0': ( '7.10.0': (
doc: SavedObjectUnsanitizedDoc<UnsanitizedCaseConnector> doc: SavedObjectUnsanitizedDoc<UnsanitizedCaseConnector>
@ -184,4 +191,5 @@ export const caseMigrations = {
'7.15.0': caseConnectorIdMigration, '7.15.0': caseConnectorIdMigration,
'8.1.0': removeCaseType, '8.1.0': removeCaseType,
'8.3.0': pipeMigrations(addDuration, addSeverity), '8.3.0': pipeMigrations(addDuration, addSeverity),
'8.5.0': addAssignees,
}; };

View file

@ -0,0 +1,77 @@
/*
* 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 { ActionTypes } from '../../../../common/api';
import { CASE_USER_ACTION_SAVED_OBJECT } from '../../../../common/constants';
import { addAssigneesToCreateUserAction } from './assignees';
describe('assignees migration', () => {
const userAction = {
type: CASE_USER_ACTION_SAVED_OBJECT,
id: '1',
attributes: {
action_at: '2022-01-09T22:00:00.000Z',
action_by: {
email: 'elastic@elastic.co',
full_name: 'Elastic User',
username: 'elastic',
},
payload: {},
type: ActionTypes.create_case,
},
};
it('adds the assignees field to the create_case user action', () => {
// @ts-expect-error payload does not include the required fields
const migratedUserAction = addAssigneesToCreateUserAction(userAction);
expect(migratedUserAction).toEqual({
attributes: {
action_at: '2022-01-09T22:00:00.000Z',
action_by: {
email: 'elastic@elastic.co',
full_name: 'Elastic User',
username: 'elastic',
},
payload: {
assignees: [],
},
type: 'create_case',
},
id: '1',
references: [],
type: 'cases-user-actions',
});
});
it('does NOT add the assignees field non-create_case user actions', () => {
Object.keys(ActionTypes)
.filter((type) => type !== ActionTypes.create_case)
.forEach((type) => {
const migratedUserAction = addAssigneesToCreateUserAction({
...userAction,
// @ts-expect-error override the type, it is only expecting create_case
attributes: { ...userAction.attributes, type },
});
expect(migratedUserAction).toEqual({
attributes: {
action_at: '2022-01-09T22:00:00.000Z',
action_by: {
email: 'elastic@elastic.co',
full_name: 'Elastic User',
username: 'elastic',
},
payload: {},
type,
},
id: '1',
references: [],
type: 'cases-user-actions',
});
});
});
});

View file

@ -0,0 +1,23 @@
/*
* 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 { SavedObjectUnsanitizedDoc, SavedObjectSanitizedDoc } from '@kbn/core/server';
import { ActionTypes, CreateCaseUserAction } from '../../../../common/api';
export const addAssigneesToCreateUserAction = (
doc: SavedObjectUnsanitizedDoc<CreateCaseUserAction>
): SavedObjectSanitizedDoc<CreateCaseUserAction> => {
if (doc.attributes.type !== ActionTypes.create_case) {
return { ...doc, references: doc.references ?? [] };
}
const payload = {
...doc.attributes.payload,
assignees: doc?.attributes?.payload?.assignees ?? [],
};
return { ...doc, attributes: { ...doc.attributes, payload }, references: doc.references ?? [] };
};

View file

@ -32,6 +32,7 @@ import { payloadMigration } from './payload';
import { addSeverityToCreateUserAction } from './severity'; import { addSeverityToCreateUserAction } from './severity';
import { UserActions } from './types'; import { UserActions } from './types';
import { getAllPersistableAttachmentMigrations } from '../get_all_persistable_attachment_migrations'; import { getAllPersistableAttachmentMigrations } from '../get_all_persistable_attachment_migrations';
import { addAssigneesToCreateUserAction } from './assignees';
export interface UserActionsMigrationsDeps { export interface UserActionsMigrationsDeps {
persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry; persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry;
@ -98,6 +99,7 @@ export const createUserActionsMigrations = (
'8.0.0': removeRuleInformation, '8.0.0': removeRuleInformation,
'8.1.0': payloadMigration, '8.1.0': payloadMigration,
'8.3.0': addSeverityToCreateUserAction, '8.3.0': addSeverityToCreateUserAction,
'8.5.0': addAssigneesToCreateUserAction,
}; };
return mergeSavedObjectMigrationMaps(persistableStateAttachmentMigrations, userActionsMigrations); return mergeSavedObjectMigrationMaps(persistableStateAttachmentMigrations, userActionsMigrations);

View file

@ -157,6 +157,7 @@ describe('CasesService', () => {
} = unsecuredSavedObjectsClient.update.mock.calls[0][2] as Partial<ESCaseAttributes>; } = unsecuredSavedObjectsClient.update.mock.calls[0][2] as Partial<ESCaseAttributes>;
expect(restUpdateAttributes).toMatchInlineSnapshot(` expect(restUpdateAttributes).toMatchInlineSnapshot(`
Object { Object {
"assignees": Array [],
"closed_at": null, "closed_at": null,
"closed_by": null, "closed_by": null,
"created_at": "2019-11-25T21:54:48.952Z", "created_at": "2019-11-25T21:54:48.952Z",
@ -481,6 +482,7 @@ describe('CasesService', () => {
expect(creationAttributes.external_service).not.toHaveProperty('connector_id'); expect(creationAttributes.external_service).not.toHaveProperty('connector_id');
expect(creationAttributes).toMatchInlineSnapshot(` expect(creationAttributes).toMatchInlineSnapshot(`
Object { Object {
"assignees": Array [],
"closed_at": null, "closed_at": null,
"closed_by": null, "closed_by": null,
"connector": Object { "connector": Object {

View file

@ -126,14 +126,17 @@ export const basicCaseFields: CaseAttributes = {
syncAlerts: true, syncAlerts: true,
}, },
owner: SECURITY_SOLUTION_OWNER, owner: SECURITY_SOLUTION_OWNER,
assignees: [],
}; };
export const createCaseSavedObjectResponse = ({ export const createCaseSavedObjectResponse = ({
connector, connector,
externalService, externalService,
overrides,
}: { }: {
connector?: ESCaseConnectorWithId; connector?: ESCaseConnectorWithId;
externalService?: CaseFullExternalService; externalService?: CaseFullExternalService;
overrides?: Partial<CaseAttributes>;
} = {}): SavedObject<ESCaseAttributes> => { } = {}): SavedObject<ESCaseAttributes> => {
const references: SavedObjectReference[] = createSavedObjectReferences({ const references: SavedObjectReference[] = createSavedObjectReferences({
connector, connector,
@ -168,6 +171,7 @@ export const createCaseSavedObjectResponse = ({
id: '1', id: '1',
attributes: { attributes: {
...basicCaseFields, ...basicCaseFields,
...overrides,
// if connector is null we'll default this to an incomplete jira value because the service // if connector is null we'll default this to an incomplete jira value because the service
// should switch it to a none connector when the id can't be found in the references array // should switch it to a none connector when the id can't be found in the references array
connector: formattedConnector, connector: formattedConnector,

View file

@ -546,6 +546,47 @@ describe('UserActionBuilder', () => {
`); `);
}); });
it('builds an assign user action correctly', () => {
const builder = builderFactory.getBuilder(ActionTypes.assignees)!;
const userAction = builder.build({
payload: { assignees: [{ uid: '1' }, { uid: '2' }] },
...commonArgs,
});
expect(userAction).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"action": "add",
"created_at": "2022-01-09T22:00:00.000Z",
"created_by": Object {
"email": "elastic@elastic.co",
"full_name": "Elastic User",
"username": "elastic",
},
"owner": "securitySolution",
"payload": Object {
"assignees": Array [
Object {
"uid": "1",
},
Object {
"uid": "2",
},
],
},
"type": "assignees",
},
"references": Array [
Object {
"id": "123",
"name": "associated-cases",
"type": "cases",
},
],
}
`);
});
it('builds a settings user action correctly', () => { it('builds a settings user action correctly', () => {
const builder = builderFactory.getBuilder(ActionTypes.settings)!; const builder = builderFactory.getBuilder(ActionTypes.settings)!;
const userAction = builder.build({ const userAction = builder.build({
@ -601,6 +642,11 @@ describe('UserActionBuilder', () => {
}, },
"owner": "securitySolution", "owner": "securitySolution",
"payload": Object { "payload": Object {
"assignees": Array [
Object {
"uid": "1",
},
],
"connector": Object { "connector": Object {
"fields": Object { "fields": Object {
"category": "Denial of Service", "category": "Denial of Service",

View file

@ -20,8 +20,10 @@ import { UserActionBuilder } from './abstract_builder';
import { SeverityUserActionBuilder } from './builders/severity'; import { SeverityUserActionBuilder } from './builders/severity';
import { PersistableStateAttachmentTypeRegistry } from '../../attachment_framework/persistable_state_registry'; import { PersistableStateAttachmentTypeRegistry } from '../../attachment_framework/persistable_state_registry';
import { BuilderDeps } from './types'; import { BuilderDeps } from './types';
import { AssigneesUserActionBuilder } from './builders/assignees';
const builderMap = { const builderMap = {
assignees: AssigneesUserActionBuilder,
title: TitleUserActionBuilder, title: TitleUserActionBuilder,
create_case: CreateCaseUserActionBuilder, create_case: CreateCaseUserActionBuilder,
connector: ConnectorUserActionBuilder, connector: ConnectorUserActionBuilder,

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ActionTypes, Actions } from '../../../../common/api';
import { UserActionBuilder } from '../abstract_builder';
import { UserActionParameters, BuilderReturnValue } from '../types';
export class AssigneesUserActionBuilder extends UserActionBuilder {
build(args: UserActionParameters<'assignees'>): BuilderReturnValue {
return this.buildCommonUserAction({
...args,
action: args.action ?? Actions.add,
valueKey: 'assignees',
value: args.payload.assignees,
type: ActionTypes.assignees,
});
}
}

View file

@ -13,11 +13,13 @@ import {
SavedObjectReference, SavedObjectReference,
SavedObjectsFindResponse, SavedObjectsFindResponse,
SavedObjectsFindResult, SavedObjectsFindResult,
SavedObjectsUpdateResponse,
} from '@kbn/core/server'; } from '@kbn/core/server';
import { ACTION_SAVED_OBJECT_TYPE } from '@kbn/actions-plugin/server'; import { ACTION_SAVED_OBJECT_TYPE } from '@kbn/actions-plugin/server';
import { import {
Actions, Actions,
ActionTypes, ActionTypes,
CaseAttributes,
CaseSeverity, CaseSeverity,
CaseStatuses, CaseStatuses,
CaseUserActionAttributes, CaseUserActionAttributes,
@ -39,6 +41,7 @@ import {
} from '../../common/constants'; } from '../../common/constants';
import { import {
createCaseSavedObjectResponse,
createConnectorObject, createConnectorObject,
createExternalService, createExternalService,
createJiraConnector, createJiraConnector,
@ -51,6 +54,9 @@ import {
updatedCases, updatedCases,
comment, comment,
attachments, attachments,
updatedAssigneesCases,
originalCasesWithAssignee,
updatedTagsCases,
} from './mocks'; } from './mocks';
import { CaseUserActionService, transformFindResponseToExternalModel } from '.'; import { CaseUserActionService, transformFindResponseToExternalModel } from '.';
import { PersistableStateAttachmentTypeRegistry } from '../../attachment_framework/persistable_state_registry'; import { PersistableStateAttachmentTypeRegistry } from '../../attachment_framework/persistable_state_registry';
@ -667,6 +673,7 @@ describe('CaseUserActionService', () => {
type: 'create_case', type: 'create_case',
owner: 'securitySolution', owner: 'securitySolution',
payload: { payload: {
assignees: [{ uid: '1' }],
connector: { connector: {
fields: { fields: {
category: 'Denial of Service', category: 'Denial of Service',
@ -1068,6 +1075,267 @@ describe('CaseUserActionService', () => {
{ refresh: undefined } { refresh: undefined }
); );
}); });
it('creates the correct user actions when an assignee is added', async () => {
await service.bulkCreateUpdateCase({
...commonArgs,
originalCases,
updatedCases: updatedAssigneesCases,
user: commonArgs.user,
});
expect(unsecuredSavedObjectsClient.bulkCreate.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Array [
Object {
"attributes": Object {
"action": "add",
"created_at": "2022-01-09T22:00:00.000Z",
"created_by": Object {
"email": "elastic@elastic.co",
"full_name": "Elastic User",
"username": "elastic",
},
"owner": "securitySolution",
"payload": Object {
"assignees": Array [
Object {
"uid": "1",
},
],
},
"type": "assignees",
},
"references": Array [
Object {
"id": "1",
"name": "associated-cases",
"type": "cases",
},
],
"type": "cases-user-actions",
},
],
Object {
"refresh": undefined,
},
]
`);
});
it('creates the correct user actions when an assignee is removed', async () => {
const casesWithAssigneeRemoved: Array<SavedObjectsUpdateResponse<CaseAttributes>> = [
{
...createCaseSavedObjectResponse(),
id: '1',
attributes: {
assignees: [],
},
},
];
await service.bulkCreateUpdateCase({
...commonArgs,
originalCases: originalCasesWithAssignee,
updatedCases: casesWithAssigneeRemoved,
user: commonArgs.user,
});
expect(unsecuredSavedObjectsClient.bulkCreate.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Array [
Object {
"attributes": Object {
"action": "delete",
"created_at": "2022-01-09T22:00:00.000Z",
"created_by": Object {
"email": "elastic@elastic.co",
"full_name": "Elastic User",
"username": "elastic",
},
"owner": "securitySolution",
"payload": Object {
"assignees": Array [
Object {
"uid": "1",
},
],
},
"type": "assignees",
},
"references": Array [
Object {
"id": "1",
"name": "associated-cases",
"type": "cases",
},
],
"type": "cases-user-actions",
},
],
Object {
"refresh": undefined,
},
]
`);
});
it('creates the correct user actions when assignees are added and removed', async () => {
const caseAssignees: Array<SavedObjectsUpdateResponse<CaseAttributes>> = [
{
...createCaseSavedObjectResponse(),
id: '1',
attributes: {
assignees: [{ uid: '2' }],
},
},
];
await service.bulkCreateUpdateCase({
...commonArgs,
originalCases: originalCasesWithAssignee,
updatedCases: caseAssignees,
user: commonArgs.user,
});
expect(unsecuredSavedObjectsClient.bulkCreate.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Array [
Object {
"attributes": Object {
"action": "add",
"created_at": "2022-01-09T22:00:00.000Z",
"created_by": Object {
"email": "elastic@elastic.co",
"full_name": "Elastic User",
"username": "elastic",
},
"owner": "securitySolution",
"payload": Object {
"assignees": Array [
Object {
"uid": "2",
},
],
},
"type": "assignees",
},
"references": Array [
Object {
"id": "1",
"name": "associated-cases",
"type": "cases",
},
],
"type": "cases-user-actions",
},
Object {
"attributes": Object {
"action": "delete",
"created_at": "2022-01-09T22:00:00.000Z",
"created_by": Object {
"email": "elastic@elastic.co",
"full_name": "Elastic User",
"username": "elastic",
},
"owner": "securitySolution",
"payload": Object {
"assignees": Array [
Object {
"uid": "1",
},
],
},
"type": "assignees",
},
"references": Array [
Object {
"id": "1",
"name": "associated-cases",
"type": "cases",
},
],
"type": "cases-user-actions",
},
],
Object {
"refresh": undefined,
},
]
`);
});
it('creates the correct user actions when tags are added and removed', async () => {
await service.bulkCreateUpdateCase({
...commonArgs,
originalCases,
updatedCases: updatedTagsCases,
user: commonArgs.user,
});
expect(unsecuredSavedObjectsClient.bulkCreate.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Array [
Object {
"attributes": Object {
"action": "add",
"created_at": "2022-01-09T22:00:00.000Z",
"created_by": Object {
"email": "elastic@elastic.co",
"full_name": "Elastic User",
"username": "elastic",
},
"owner": "securitySolution",
"payload": Object {
"tags": Array [
"a",
"b",
],
},
"type": "tags",
},
"references": Array [
Object {
"id": "1",
"name": "associated-cases",
"type": "cases",
},
],
"type": "cases-user-actions",
},
Object {
"attributes": Object {
"action": "delete",
"created_at": "2022-01-09T22:00:00.000Z",
"created_by": Object {
"email": "elastic@elastic.co",
"full_name": "Elastic User",
"username": "elastic",
},
"owner": "securitySolution",
"payload": Object {
"tags": Array [
"defacement",
],
},
"type": "tags",
},
"references": Array [
Object {
"id": "1",
"name": "associated-cases",
"type": "cases",
},
],
"type": "cases-user-actions",
},
],
Object {
"refresh": undefined,
},
]
`);
});
}); });
describe('bulkCreateAttachmentDeletion', () => { describe('bulkCreateAttachmentDeletion', () => {

View file

@ -27,12 +27,16 @@ import {
isCommentUserAction, isCommentUserAction,
} from '../../../common/utils/user_actions'; } from '../../../common/utils/user_actions';
import { import {
ActionOperationValues,
Actions, Actions,
ActionTypes, ActionTypes,
ActionTypeValues,
CaseAttributes, CaseAttributes,
CaseUserActionAttributes, CaseUserActionAttributes,
CaseUserActionAttributesWithoutConnectorId, CaseUserActionAttributesWithoutConnectorId,
CaseUserActionResponse, CaseUserActionResponse,
CaseUserProfile,
CaseAssignees,
CommentRequest, CommentRequest,
NONE_CONNECTOR_ID, NONE_CONNECTOR_ID,
User, User,
@ -53,13 +57,19 @@ import {
} from '../../common/constants'; } from '../../common/constants';
import { findConnectorIdReference } from '../transform'; import { findConnectorIdReference } from '../transform';
import { buildFilter, combineFilters, arraysDifference } from '../../client/utils'; import { buildFilter, combineFilters, arraysDifference } from '../../client/utils';
import { BuilderParameters, BuilderReturnValue, CommonArguments, CreateUserAction } from './types'; import {
BuilderParameters,
BuilderReturnValue,
CommonArguments,
CreateUserAction,
UserActionParameters,
} from './types';
import { BuilderFactory } from './builder_factory'; import { BuilderFactory } from './builder_factory';
import { defaultSortField, isCommentRequestTypeExternalReferenceSO } from '../../common/utils'; import { defaultSortField, isCommentRequestTypeExternalReferenceSO } from '../../common/utils';
import { PersistableStateAttachmentTypeRegistry } from '../../attachment_framework/persistable_state_registry'; import { PersistableStateAttachmentTypeRegistry } from '../../attachment_framework/persistable_state_registry';
import { injectPersistableReferencesToSO } from '../../attachment_framework/so_references'; import { injectPersistableReferencesToSO } from '../../attachment_framework/so_references';
import { IndexRefresh } from '../types'; import { IndexRefresh } from '../types';
import { isStringArray } from './type_guards'; import { isAssigneesArray, isStringArray } from './type_guards';
interface GetCaseUserActionArgs extends ClientArgs { interface GetCaseUserActionArgs extends ClientArgs {
caseId: string; caseId: string;
@ -92,6 +102,11 @@ interface GetUserActionItemByDifference extends CommonUserActionArgs {
newValue: unknown; newValue: unknown;
} }
interface TypedUserActionDiffedItems<T> extends GetUserActionItemByDifference {
originalValue: T[];
newValue: T[];
}
interface BulkCreateBulkUpdateCaseUserActions extends ClientArgs, IndexRefresh { interface BulkCreateBulkUpdateCaseUserActions extends ClientArgs, IndexRefresh {
originalCases: Array<SavedObject<CaseAttributes>>; originalCases: Array<SavedObject<CaseAttributes>>;
updatedCases: Array<SavedObjectsUpdateResponse<CaseAttributes>>; updatedCases: Array<SavedObjectsUpdateResponse<CaseAttributes>>;
@ -106,6 +121,10 @@ type CreateUserActionClient<T extends keyof BuilderParameters> = CreateUserActio
CommonUserActionArgs & CommonUserActionArgs &
IndexRefresh; IndexRefresh;
type CreatePayloadFunction<Item, ActionType extends ActionTypeValues> = (
items: Item[]
) => UserActionParameters<ActionType>['payload'];
export class CaseUserActionService { export class CaseUserActionService {
private static readonly userActionFieldsAllowed: Set<string> = new Set(Object.keys(ActionTypes)); private static readonly userActionFieldsAllowed: Set<string> = new Set(Object.keys(ActionTypes));
@ -120,55 +139,26 @@ export class CaseUserActionService {
}); });
} }
private getUserActionItemByDifference({ private getUserActionItemByDifference(
field, params: GetUserActionItemByDifference
originalValue, ): BuilderReturnValue[] {
newValue, const { field, originalValue, newValue, caseId, owner, user } = params;
caseId,
owner,
user,
}: GetUserActionItemByDifference): BuilderReturnValue[] {
if (!CaseUserActionService.userActionFieldsAllowed.has(field)) { if (!CaseUserActionService.userActionFieldsAllowed.has(field)) {
return []; return [];
} } else if (
field === ActionTypes.assignees &&
if (field === ActionTypes.tags && isStringArray(originalValue) && isStringArray(newValue)) { isAssigneesArray(originalValue) &&
const tagsUserActionBuilder = this.builderFactory.getBuilder(ActionTypes.tags); isAssigneesArray(newValue)
const compareValues = arraysDifference(originalValue, newValue); ) {
const userActions = []; return this.buildAssigneesUserActions({ ...params, originalValue, newValue });
} else if (
if (compareValues && compareValues.addedItems.length > 0) { field === ActionTypes.tags &&
const tagAddUserAction = tagsUserActionBuilder?.build({ isStringArray(originalValue) &&
action: Actions.add, isStringArray(newValue)
caseId, ) {
user, return this.buildTagsUserActions({ ...params, originalValue, newValue });
owner, } else if (isUserActionType(field) && newValue != null) {
payload: { tags: compareValues.addedItems },
});
if (tagAddUserAction) {
userActions.push(tagAddUserAction);
}
}
if (compareValues && compareValues.deletedItems.length > 0) {
const tagsDeleteUserAction = tagsUserActionBuilder?.build({
action: Actions.delete,
caseId,
user,
owner,
payload: { tags: compareValues.deletedItems },
});
if (tagsDeleteUserAction) {
userActions.push(tagsDeleteUserAction);
}
}
return userActions;
}
if (isUserActionType(field) && newValue != null) {
const userActionBuilder = this.builderFactory.getBuilder(ActionTypes[field]); const userActionBuilder = this.builderFactory.getBuilder(ActionTypes[field]);
const fieldUserAction = userActionBuilder?.build({ const fieldUserAction = userActionBuilder?.build({
caseId, caseId,
@ -183,6 +173,85 @@ export class CaseUserActionService {
return []; return [];
} }
private buildAssigneesUserActions(params: TypedUserActionDiffedItems<CaseUserProfile>) {
const createPayload: CreatePayloadFunction<CaseUserProfile, typeof ActionTypes.assignees> = (
items: CaseAssignees
) => ({ assignees: items });
return this.buildAddDeleteUserActions(params, createPayload, ActionTypes.assignees);
}
private buildTagsUserActions(params: TypedUserActionDiffedItems<string>) {
const createPayload: CreatePayloadFunction<string, typeof ActionTypes.tags> = (
items: string[]
) => ({
tags: items,
});
return this.buildAddDeleteUserActions(params, createPayload, ActionTypes.tags);
}
private buildAddDeleteUserActions<Item, ActionType extends ActionTypeValues>(
params: TypedUserActionDiffedItems<Item>,
createPayload: CreatePayloadFunction<Item, ActionType>,
actionType: ActionType
) {
const { originalValue, newValue } = params;
const compareValues = arraysDifference(originalValue, newValue);
const addUserAction = this.buildUserAction({
commonArgs: params,
actionType,
action: Actions.add,
createPayload,
modifiedItems: compareValues?.addedItems,
});
const deleteUserAction = this.buildUserAction({
commonArgs: params,
actionType,
action: Actions.delete,
createPayload,
modifiedItems: compareValues?.deletedItems,
});
return [
...(addUserAction ? [addUserAction] : []),
...(deleteUserAction ? [deleteUserAction] : []),
];
}
private buildUserAction<Item, ActionType extends ActionTypeValues>({
commonArgs,
actionType,
action,
createPayload,
modifiedItems,
}: {
commonArgs: CommonUserActionArgs;
actionType: ActionType;
action: ActionOperationValues;
createPayload: CreatePayloadFunction<Item, ActionType>;
modifiedItems?: Item[] | null;
}) {
const userActionBuilder = this.builderFactory.getBuilder(actionType);
if (!userActionBuilder || !modifiedItems || modifiedItems.length <= 0) {
return;
}
const { caseId, owner, user } = commonArgs;
const userAction = userActionBuilder.build({
action,
caseId,
user,
owner,
payload: createPayload(modifiedItems),
});
return userAction;
}
public async bulkCreateCaseDeletion({ public async bulkCreateCaseDeletion({
unsecuredSavedObjectsClient, unsecuredSavedObjectsClient,
cases, cases,

View file

@ -7,11 +7,17 @@
import { CASE_SAVED_OBJECT } from '../../../common/constants'; import { CASE_SAVED_OBJECT } from '../../../common/constants';
import { SECURITY_SOLUTION_OWNER } from '../../../common'; import { SECURITY_SOLUTION_OWNER } from '../../../common';
import { CaseSeverity, CaseStatuses, CommentType, ConnectorTypes } from '../../../common/api'; import {
CasePostRequest,
CaseSeverity,
CaseStatuses,
CommentType,
ConnectorTypes,
} from '../../../common/api';
import { createCaseSavedObjectResponse } from '../test_utils'; import { createCaseSavedObjectResponse } from '../test_utils';
import { transformSavedObjectToExternalModel } from '../cases/transform'; import { transformSavedObjectToExternalModel } from '../cases/transform';
export const casePayload = { export const casePayload: CasePostRequest = {
title: 'Case SIR', title: 'Case SIR',
tags: ['sir'], tags: ['sir'],
description: 'testing sir', description: 'testing sir',
@ -32,6 +38,7 @@ export const casePayload = {
settings: { syncAlerts: true }, settings: { syncAlerts: true },
severity: CaseSeverity.LOW, severity: CaseSeverity.LOW,
owner: SECURITY_SOLUTION_OWNER, owner: SECURITY_SOLUTION_OWNER,
assignees: [{ uid: '1' }],
}; };
export const externalService = { export const externalService = {
@ -76,6 +83,30 @@ export const updatedCases = [
}, },
]; ];
export const originalCasesWithAssignee = [
{ ...createCaseSavedObjectResponse({ overrides: { assignees: [{ uid: '1' }] } }), id: '1' },
].map((so) => transformSavedObjectToExternalModel(so));
export const updatedAssigneesCases = [
{
...createCaseSavedObjectResponse(),
id: '1',
attributes: {
assignees: [{ uid: '1' }],
},
},
];
export const updatedTagsCases = [
{
...createCaseSavedObjectResponse(),
id: '1',
attributes: {
tags: ['a', 'b'],
},
},
];
export const comment = { export const comment = {
comment: 'a comment', comment: 'a comment',
type: CommentType.user as const, type: CommentType.user as const,

View file

@ -5,7 +5,7 @@
* 2.0. * 2.0.
*/ */
import { isObjectArray, isStringArray } from './type_guards'; import { isAssigneesArray, isStringArray } from './type_guards';
describe('type_guards', () => { describe('type_guards', () => {
describe('isStringArray', () => { describe('isStringArray', () => {
@ -30,25 +30,33 @@ describe('type_guards', () => {
}); });
}); });
describe('isObjectArray', () => { describe('isAssigneesArray', () => {
it('returns true when the value is an empty array', () => { it('returns true when the value is an empty array', () => {
expect(isObjectArray([])).toBeTruthy(); expect(isAssigneesArray([])).toBeTruthy();
}); });
it('returns true when the value is an array of a single string', () => { it('returns false when the value is not an array of assignees', () => {
expect(isObjectArray([{ a: '1' }])).toBeTruthy(); expect(isAssigneesArray([{ a: '1' }])).toBeFalsy();
}); });
it('returns true when the value is an array of multiple strings', () => { it('returns false when the value is an array of assignees and non assignee objects', () => {
expect(isObjectArray([{ a: 'a' }, { b: 'b' }])).toBeTruthy(); expect(isAssigneesArray([{ uid: '1' }, { hi: '2' }])).toBeFalsy();
}); });
it('returns false when the value is an array of strings and numbers', () => { it('returns true when the value is an array of a single assignee', () => {
expect(isObjectArray([{ a: 'a' }, 1])).toBeFalsy(); expect(isAssigneesArray([{ uid: '1' }])).toBeTruthy();
});
it('returns true when the value is an array of multiple assignees', () => {
expect(isAssigneesArray([{ uid: 'a' }, { uid: 'b' }])).toBeTruthy();
});
it('returns false when the value is an array of assignees and numbers', () => {
expect(isAssigneesArray([{ uid: 'a' }, 1])).toBeFalsy();
}); });
it('returns false when the value is an array of strings and objects', () => { it('returns false when the value is an array of strings and objects', () => {
expect(isObjectArray(['a', {}])).toBeFalsy(); expect(isAssigneesArray(['a', {}])).toBeFalsy();
}); });
}); });
}); });

View file

@ -5,12 +5,13 @@
* 2.0. * 2.0.
*/ */
import { isPlainObject, isString } from 'lodash'; import { isString } from 'lodash';
import { CaseAssignees, CaseAssigneesRt } from '../../../common/api/cases/assignee';
export const isStringArray = (value: unknown): value is string[] => { export const isStringArray = (value: unknown): value is string[] => {
return Array.isArray(value) && value.every((val) => isString(val)); return Array.isArray(value) && value.every((val) => isString(val));
}; };
export const isObjectArray = (value: unknown): value is Array<Record<string, unknown>> => { export const isAssigneesArray = (value: unknown): value is CaseAssignees => {
return Array.isArray(value) && value.every((val) => isPlainObject(val)); return CaseAssigneesRt.is(value);
}; };

View file

@ -6,6 +6,7 @@
*/ */
import { SavedObjectReference } from '@kbn/core/server'; import { SavedObjectReference } from '@kbn/core/server';
import { CaseAssignees } from '../../../common/api/cases/assignee';
import { import {
CasePostRequest, CasePostRequest,
CaseSettings, CaseSettings,
@ -36,6 +37,9 @@ export interface BuilderParameters {
tags: { tags: {
parameters: { payload: { tags: string[] } }; parameters: { payload: { tags: string[] } };
}; };
assignees: {
parameters: { payload: { assignees: CaseAssignees } };
};
pushed: { pushed: {
parameters: { parameters: {
payload: { payload: {

View file

@ -23,7 +23,14 @@ export interface FixtureStartDeps {
export class FixturePlugin implements Plugin<void, void, FixtureSetupDeps, FixtureStartDeps> { export class FixturePlugin implements Plugin<void, void, FixtureSetupDeps, FixtureStartDeps> {
public setup(core: CoreSetup<FixtureStartDeps>, deps: FixtureSetupDeps) { public setup(core: CoreSetup<FixtureStartDeps>, deps: FixtureSetupDeps) {
const { features } = deps; const { features } = deps;
this.registerFeatures(features);
}
public start() {}
public stop() {}
private registerFeatures(features: FeaturesPluginSetup) {
features.registerKibanaFeature({ features.registerKibanaFeature({
id: 'securitySolutionFixture', id: 'securitySolutionFixture',
name: 'SecuritySolutionFixture', name: 'SecuritySolutionFixture',
@ -118,6 +125,4 @@ export class FixturePlugin implements Plugin<void, void, FixtureSetupDeps, Fixtu
}, },
}); });
} }
public start() {}
public stop() {}
} }

View file

@ -7,9 +7,10 @@
import { FtrProviderContext as CommonFtrProviderContext } from '../../ftr_provider_context'; import { FtrProviderContext as CommonFtrProviderContext } from '../../ftr_provider_context';
import { Role, User, UserInfo } from './types'; import { Role, User, UserInfo } from './types';
import { users } from './users'; import { obsOnly, secOnly, secOnlyNoDelete, secOnlyRead, users } from './users';
import { roles } from './roles'; import { roles } from './roles';
import { spaces } from './spaces'; import { spaces } from './spaces';
import { loginUsers } from '../utils';
export const getUserInfo = (user: User): UserInfo => ({ export const getUserInfo = (user: User): UserInfo => ({
username: user.username, username: user.username,
@ -103,3 +104,12 @@ export const deleteSpacesAndUsers = async (getService: CommonFtrProviderContext[
await deleteSpaces(getService); await deleteSpaces(getService);
await deleteUsersAndRoles(getService); await deleteUsersAndRoles(getService);
}; };
export const activateUserProfiles = async (getService: CommonFtrProviderContext['getService']) => {
const supertestWithoutAuth = getService('supertestWithoutAuth');
await loginUsers({
supertest: supertestWithoutAuth,
users: [secOnly, secOnlyNoDelete, secOnlyRead, obsOnly],
});
};

View file

@ -45,6 +45,7 @@ export const postCaseReq: CasePostRequest = {
syncAlerts: true, syncAlerts: true,
}, },
owner: 'securitySolutionFixture', owner: 'securitySolutionFixture',
assignees: [],
}; };
/** /**

View file

@ -0,0 +1,39 @@
/*
* 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 SuperTest from 'supertest';
import { UserProfileBulkGetParams, UserProfileServiceStart } from '@kbn/security-plugin/server';
import { superUser } from './authentication/users';
import { User } from './authentication/types';
import { getSpaceUrlPrefix } from './utils';
type BulkGetUserProfilesParams = Omit<UserProfileBulkGetParams, 'uids'> & { uids: string[] };
export const bulkGetUserProfiles = async ({
supertest,
req,
expectedHttpCode = 200,
auth = { user: superUser, space: null },
}: {
supertest: SuperTest.SuperTest<SuperTest.Test>;
req: BulkGetUserProfilesParams;
expectedHttpCode?: number;
auth?: { user: User; space: string | null };
}): ReturnType<UserProfileServiceStart['bulkGet']> => {
const { uids, ...restParams } = req;
const uniqueIDs = [...new Set(uids)];
const { body: profiles } = await supertest
.post(`${getSpaceUrlPrefix(auth.space)}/internal/security/user_profile/_bulk_get`)
.auth(auth.user.username, auth.user.password)
.set('kbn-xsrf', 'true')
.send({ uids: uniqueIDs, ...restParams })
.expect(expectedHttpCode);
return profiles;
};

View file

@ -6,13 +6,20 @@
*/ */
import { FtrProviderContext } from '../../../common/ftr_provider_context'; import { FtrProviderContext } from '../../../common/ftr_provider_context';
import { createSpacesAndUsers, deleteSpacesAndUsers } from '../../../common/lib/authentication'; import {
createSpacesAndUsers,
deleteSpacesAndUsers,
activateUserProfiles,
} from '../../../common/lib/authentication';
// eslint-disable-next-line import/no-default-export // eslint-disable-next-line import/no-default-export
export default ({ loadTestFile, getService }: FtrProviderContext): void => { export default ({ loadTestFile, getService }: FtrProviderContext): void => {
describe('cases security and spaces enabled: basic', function () { describe('cases security and spaces enabled: basic', function () {
before(async () => { before(async () => {
await createSpacesAndUsers(getService); await createSpacesAndUsers(getService);
// once a user profile is created the only way to remove it is to delete the user and roles, so best to activate
// before all the tests
await activateUserProfiles(getService);
}); });
after(async () => { after(async () => {

View file

@ -0,0 +1,252 @@
/*
* 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 { findCasesResp, getPostCaseRequest, postCaseReq } from '../../../../common/lib/mock';
import {
createCase,
suggestUserProfiles,
getCase,
findCases,
updateCase,
deleteAllCaseItems,
} from '../../../../common/lib/utils';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
import { bulkGetUserProfiles } from '../../../../common/lib/user_profiles';
import { superUser } from '../../../../common/lib/authentication/users';
// eslint-disable-next-line import/no-default-export
export default ({ getService }: FtrProviderContext): void => {
const es = getService('es');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const supertest = getService('supertest');
describe('assignees', () => {
afterEach(async () => {
await deleteAllCaseItems(es);
});
it('allows the assignees field to be an empty array', async () => {
const postedCase = await createCase(supertest, getPostCaseRequest());
expect(postedCase.assignees).to.eql([]);
});
it('allows creating a case without the assignees field in the request', async () => {
const postReq = getPostCaseRequest();
const { assignees, ...restRequest } = postReq;
const postedCase = await createCase(supertest, restRequest);
expect(postedCase.assignees).to.eql([]);
});
it('assigns a user to a case and retrieves the users profile', async () => {
const profile = await suggestUserProfiles({
supertest: supertestWithoutAuth,
req: {
name: 'delete',
owners: ['securitySolutionFixture'],
size: 1,
},
auth: { user: superUser, space: 'space1' },
});
const postedCase = await createCase(
supertest,
getPostCaseRequest({
assignees: [{ uid: profile[0].uid }],
})
);
const retrievedProfiles = await bulkGetUserProfiles({
supertest,
req: {
uids: postedCase.assignees.map((assignee) => assignee.uid),
dataPath: 'avatar',
},
});
expect(retrievedProfiles[0]).to.eql(profile[0]);
});
it('assigns multiple users to a case and retrieves their profiles', async () => {
const profiles = await suggestUserProfiles({
supertest: supertestWithoutAuth,
req: {
name: 'only',
owners: ['securitySolutionFixture'],
size: 2,
},
auth: { user: superUser, space: 'space1' },
});
const postedCase = await createCase(
supertest,
getPostCaseRequest({
assignees: profiles.map((profile) => ({ uid: profile.uid })),
})
);
const retrievedProfiles = await bulkGetUserProfiles({
supertest,
req: {
uids: postedCase.assignees.map((assignee) => assignee.uid),
dataPath: 'avatar',
},
});
expect(retrievedProfiles).to.eql(profiles);
});
it('assigns a user to a case and retrieves the users profile from a get case call', async () => {
const profile = await suggestUserProfiles({
supertest: supertestWithoutAuth,
req: {
name: 'delete',
owners: ['securitySolutionFixture'],
size: 1,
},
auth: { user: superUser, space: 'space1' },
});
const postedCase = await createCase(
supertest,
getPostCaseRequest({
assignees: [{ uid: profile[0].uid }],
})
);
const retrievedCase = await getCase({ caseId: postedCase.id, supertest });
const retrievedProfiles = await bulkGetUserProfiles({
supertest,
req: {
uids: retrievedCase.assignees.map((assignee) => assignee.uid),
dataPath: 'avatar',
},
});
expect(retrievedProfiles[0]).to.eql(profile[0]);
});
it('filters cases using the assigned user', async () => {
const profile = await suggestUserProfiles({
supertest: supertestWithoutAuth,
req: {
name: 'delete',
owners: ['securitySolutionFixture'],
size: 1,
},
auth: { user: superUser, space: 'space1' },
});
await createCase(supertest, postCaseReq);
const caseWithDeleteAssignee1 = await createCase(
supertest,
getPostCaseRequest({
assignees: [{ uid: profile[0].uid }],
})
);
const caseWithDeleteAssignee2 = await createCase(
supertest,
getPostCaseRequest({
assignees: [{ uid: profile[0].uid }],
})
);
const cases = await findCases({
supertest,
query: { assignees: [profile[0].uid] },
});
expect(cases).to.eql({
...findCasesResp,
total: 2,
cases: [caseWithDeleteAssignee1, caseWithDeleteAssignee2],
count_open_cases: 2,
});
});
it("filters cases using the assigned users by constructing an or'd filter", async () => {
const profileUidsToFilter = await suggestUserProfiles({
supertest: supertestWithoutAuth,
req: {
name: 'only',
owners: ['securitySolutionFixture'],
size: 2,
},
auth: { user: superUser, space: 'space1' },
});
await createCase(supertest, postCaseReq);
const caseWithDeleteAssignee1 = await createCase(
supertest,
getPostCaseRequest({
assignees: [{ uid: profileUidsToFilter[0].uid }],
})
);
const caseWithDeleteAssignee2 = await createCase(
supertest,
getPostCaseRequest({
assignees: [{ uid: profileUidsToFilter[1].uid }],
})
);
const cases = await findCases({
supertest,
query: { assignees: [profileUidsToFilter[0].uid, profileUidsToFilter[1].uid] },
});
expect(cases).to.eql({
...findCasesResp,
total: 2,
cases: [caseWithDeleteAssignee1, caseWithDeleteAssignee2],
count_open_cases: 2,
});
});
it('updates the assignees on a case', async () => {
const profiles = await suggestUserProfiles({
supertest: supertestWithoutAuth,
req: {
name: 'delete',
owners: ['securitySolutionFixture'],
size: 1,
},
auth: { user: superUser, space: 'space1' },
});
const postedCase = await createCase(supertest, getPostCaseRequest());
const patchedCases = await updateCase({
supertest,
params: {
cases: [
{
id: postedCase.id,
version: postedCase.version,
assignees: [{ uid: profiles[0].uid }],
},
],
},
});
const retrievedProfiles = await bulkGetUserProfiles({
supertest,
req: {
uids: patchedCases[0].assignees.map((assignee) => assignee.uid),
dataPath: 'avatar',
},
});
expect(retrievedProfiles).to.eql(profiles);
});
});
};

View file

@ -245,6 +245,7 @@ const expectCaseCreateUserAction = (
...restCreateCase, ...restCreateCase,
status: CaseStatuses.open, status: CaseStatuses.open,
severity: CaseSeverity.LOW, severity: CaseSeverity.LOW,
assignees: [],
}); });
expect(restParsedConnector).to.eql(restConnector); expect(restParsedConnector).to.eql(restConnector);
}; };

View file

@ -427,5 +427,54 @@ export default function createGetTests({ getService }: FtrProviderContext) {
}); });
}); });
}); });
describe('8.5.0', () => {
before(async () => {
await kibanaServer.importExport.load(
'x-pack/test/functional/fixtures/kbn_archiver/cases/8.2.0/cases_duration.json'
);
await kibanaServer.importExport.load(
'x-pack/test/functional/fixtures/kbn_archiver/cases/8.5.0/cases_assignees.json'
);
});
after(async () => {
await kibanaServer.importExport.unload(
'x-pack/test/functional/fixtures/kbn_archiver/cases/8.2.0/cases_duration.json'
);
await kibanaServer.importExport.unload(
'x-pack/test/functional/fixtures/kbn_archiver/cases/8.5.0/cases_assignees.json'
);
await deleteAllCaseItems(es);
});
describe('assignees', () => {
it('adds the assignees field for existing documents', async () => {
const caseInfo = await getCase({
supertest,
// This case exists in the 8.2.0 cases_duration.json file and does not contain an assignees field
caseId: '4537b380-a512-11ec-b92f-859b9e89e434',
});
expect(caseInfo).to.have.property('assignees');
expect(caseInfo.assignees).to.eql([]);
});
it('does not overwrite the assignees field if it already exists', async () => {
const caseInfo = await getCase({
supertest,
// This case exists in the 8.5.0 cases_assignees.json file and does contain an assignees field
caseId: '063d5820-1284-11ed-81af-63a2bdfb2bf9',
});
expect(caseInfo).to.have.property('assignees');
expect(caseInfo.assignees).to.eql([
{
uid: 'abc',
},
]);
});
});
});
}); });
} }

View file

@ -150,6 +150,7 @@ export default ({ getService }: FtrProviderContext): void => {
owner: postedCase.owner, owner: postedCase.owner,
status: CaseStatuses.open, status: CaseStatuses.open,
severity: CaseSeverity.LOW, severity: CaseSeverity.LOW,
assignees: [],
}, },
}); });
}); });

View file

@ -25,6 +25,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => {
loadTestFile(require.resolve('./cases/get_case')); loadTestFile(require.resolve('./cases/get_case'));
loadTestFile(require.resolve('./cases/patch_cases')); loadTestFile(require.resolve('./cases/patch_cases'));
loadTestFile(require.resolve('./cases/post_case')); loadTestFile(require.resolve('./cases/post_case'));
loadTestFile(require.resolve('./cases/assignees'));
loadTestFile(require.resolve('./cases/resolve_case')); loadTestFile(require.resolve('./cases/resolve_case'));
loadTestFile(require.resolve('./cases/reporters/get_reporters')); loadTestFile(require.resolve('./cases/reporters/get_reporters'));
loadTestFile(require.resolve('./cases/status/get_status')); loadTestFile(require.resolve('./cases/status/get_status'));

View file

@ -9,10 +9,7 @@ import expect from '@kbn/expect';
import { loginUsers, suggestUserProfiles } from '../../../../common/lib/utils'; import { loginUsers, suggestUserProfiles } from '../../../../common/lib/utils';
import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { FtrProviderContext } from '../../../../common/ftr_provider_context';
import { import {
secOnly,
superUser, superUser,
secOnlyNoDelete,
secOnlyRead,
obsOnly, obsOnly,
noCasesPrivilegesSpace1, noCasesPrivilegesSpace1,
} from '../../../../common/lib/authentication/users'; } from '../../../../common/lib/authentication/users';
@ -24,13 +21,6 @@ export default function ({ getService }: FtrProviderContext) {
const supertestWithoutAuth = getService('supertestWithoutAuth'); const supertestWithoutAuth = getService('supertestWithoutAuth');
describe('suggest_user_profiles', () => { describe('suggest_user_profiles', () => {
before(async () => {
await loginUsers({
supertest: supertestWithoutAuth,
users: [secOnly, secOnlyNoDelete, secOnlyRead, obsOnly],
});
});
it('finds the profile for the user without deletion privileges', async () => { it('finds the profile for the user without deletion privileges', async () => {
const profiles = await suggestUserProfiles({ const profiles = await suggestUserProfiles({
supertest: supertestWithoutAuth, supertest: supertestWithoutAuth,

View file

@ -197,6 +197,37 @@ export default ({ getService }: FtrProviderContext): void => {
expect(deleteTagsUserAction.payload).to.eql({ tags: ['defacement'] }); expect(deleteTagsUserAction.payload).to.eql({ tags: ['defacement'] });
}); });
it('creates an add and delete assignees user action', async () => {
const theCase = await createCase(
supertest,
getPostCaseRequest({ assignees: [{ uid: '1' }] })
);
await updateCase({
supertest,
params: {
cases: [
{
id: theCase.id,
version: theCase.version,
assignees: [{ uid: '2' }, { uid: '3' }],
},
],
},
});
const userActions = await getCaseUserActions({ supertest, caseID: theCase.id });
const addAssigneesUserAction = userActions[1];
const deleteAssigneesUserAction = userActions[2];
expect(userActions.length).to.eql(3);
expect(addAssigneesUserAction.type).to.eql('assignees');
expect(addAssigneesUserAction.action).to.eql('add');
expect(addAssigneesUserAction.payload).to.eql({ assignees: [{ uid: '2' }, { uid: '3' }] });
expect(deleteAssigneesUserAction.type).to.eql('assignees');
expect(deleteAssigneesUserAction.action).to.eql('delete');
expect(deleteAssigneesUserAction.payload).to.eql({ assignees: [{ uid: '1' }] });
});
it('creates an update title user action', async () => { it('creates an update title user action', async () => {
const newTitle = 'Such a great title'; const newTitle = 'Such a great title';
const theCase = await createCase(supertest, postCaseReq); const theCase = await createCase(supertest, postCaseReq);

View file

@ -78,6 +78,7 @@ export default function createGetTests({ getService }: FtrProviderContext) {
id: 'none', id: 'none',
}, },
severity: 'low', severity: 'low',
assignees: [],
owner: 'securitySolution', owner: 'securitySolution',
settings: { syncAlerts: true }, settings: { syncAlerts: true },
}, },
@ -191,6 +192,7 @@ export default function createGetTests({ getService }: FtrProviderContext) {
syncAlerts: true, syncAlerts: true,
}, },
severity: 'low', severity: 'low',
assignees: [],
owner: 'securitySolution', owner: 'securitySolution',
}, },
type: 'create_case', type: 'create_case',
@ -298,6 +300,7 @@ export default function createGetTests({ getService }: FtrProviderContext) {
syncAlerts: true, syncAlerts: true,
}, },
severity: 'low', severity: 'low',
assignees: [],
owner: 'securitySolution', owner: 'securitySolution',
}, },
type: 'create_case', type: 'create_case',
@ -327,6 +330,7 @@ export default function createGetTests({ getService }: FtrProviderContext) {
syncAlerts: true, syncAlerts: true,
}, },
severity: 'low', severity: 'low',
assignees: [],
}, },
type: 'create_case', type: 'create_case',
action_id: 'b3094de0-005e-11ec-91f1-6daf2ab59fb5', action_id: 'b3094de0-005e-11ec-91f1-6daf2ab59fb5',
@ -372,6 +376,7 @@ export default function createGetTests({ getService }: FtrProviderContext) {
}, },
owner: 'securitySolution', owner: 'securitySolution',
severity: 'low', severity: 'low',
assignees: [],
}, },
type: 'create_case', type: 'create_case',
action_id: 'e7882d70-005e-11ec-91f1-6daf2ab59fb5', action_id: 'e7882d70-005e-11ec-91f1-6daf2ab59fb5',
@ -744,6 +749,7 @@ export default function createGetTests({ getService }: FtrProviderContext) {
title: 'User actions', title: 'User actions',
owner: 'securitySolution', owner: 'securitySolution',
severity: 'low', severity: 'low',
assignees: [],
}, },
type: 'create_case', type: 'create_case',
}, },
@ -1109,6 +1115,7 @@ export default function createGetTests({ getService }: FtrProviderContext) {
title: 'User actions', title: 'User actions',
owner: 'securitySolution', owner: 'securitySolution',
severity: 'low', severity: 'low',
assignees: [],
}, },
type: 'create_case', type: 'create_case',
}); });
@ -1130,6 +1137,85 @@ export default function createGetTests({ getService }: FtrProviderContext) {
}); });
}); });
}); });
describe('8.5.0', () => {
const CASE_ID = '5257a000-5e7d-11ec-9ee9-cd64f0b77b3c';
const CREATE_UA_ID = '5275af50-5e7d-11ec-9ee9-cd64f0b77b3c';
before(async () => {
await kibanaServer.importExport.load(
'x-pack/test/functional/fixtures/kbn_archiver/cases/8.0.0/cases.json'
);
});
after(async () => {
await kibanaServer.importExport.unload(
'x-pack/test/functional/fixtures/kbn_archiver/cases/8.0.0/cases.json'
);
await deleteAllCaseItems(es);
});
describe('assignees', () => {
it('adds the assignees field to the create case user action', async () => {
const userActions = await getCaseUserActions({
supertest,
caseID: CASE_ID,
});
const createUserAction = userActions.find(
(userAction) => userAction.action_id === CREATE_UA_ID
);
expect(createUserAction).to.eql({
action: 'create',
action_id: '5275af50-5e7d-11ec-9ee9-cd64f0b77b3c',
case_id: '5257a000-5e7d-11ec-9ee9-cd64f0b77b3c',
comment_id: null,
created_at: '2021-12-16T14:34:48.709Z',
created_by: {
email: '',
full_name: '',
username: 'elastic',
},
owner: 'securitySolution',
payload: {
connector: {
fields: null,
id: 'none',
name: 'none',
type: '.none',
},
description: 'migrating user actions',
settings: {
syncAlerts: true,
},
status: 'open',
tags: ['user', 'actions'],
title: 'User actions',
owner: 'securitySolution',
severity: 'low',
assignees: [],
},
type: 'create_case',
});
});
it('does NOT add the assignees field to the other user actions', async () => {
const userActions = await getCaseUserActions({
supertest,
caseID: CASE_ID,
});
const userActionsWithoutCreateAction = userActions.filter(
(userAction) => userAction.type !== ActionTypes.create_case
);
for (const userAction of userActionsWithoutCreateAction) {
expect(userAction.payload).not.to.have.property('assignees');
}
});
});
});
}); });
} }

View file

@ -6,13 +6,20 @@
*/ */
import { FtrProviderContext } from '../../../common/ftr_provider_context'; import { FtrProviderContext } from '../../../common/ftr_provider_context';
import { createSpacesAndUsers, deleteSpacesAndUsers } from '../../../common/lib/authentication'; import {
createSpacesAndUsers,
deleteSpacesAndUsers,
activateUserProfiles,
} from '../../../common/lib/authentication';
// eslint-disable-next-line import/no-default-export // eslint-disable-next-line import/no-default-export
export default ({ loadTestFile, getService }: FtrProviderContext): void => { export default ({ loadTestFile, getService }: FtrProviderContext): void => {
describe('cases security and spaces enabled: trial', function () { describe('cases security and spaces enabled: trial', function () {
before(async () => { before(async () => {
await createSpacesAndUsers(getService); await createSpacesAndUsers(getService);
// once a user profile is created the only way to remove it is to delete the user and roles, so best to activate
// before all the tests
await activateUserProfiles(getService);
}); });
after(async () => { after(async () => {

View file

@ -0,0 +1,105 @@
{
"attributes": {
"assignees": [
{
"uid": "abc"
}
],
"closed_at": null,
"closed_by": null,
"connector": {
"fields": [],
"name": "none",
"type": ".none"
},
"created_at": "2022-08-02T16:56:16.806Z",
"created_by": {
"email": null,
"full_name": null,
"username": "elastic"
},
"description": "a case description",
"duration": null,
"external_service": null,
"owner": "cases",
"settings": {
"syncAlerts": false
},
"severity": "low",
"status": "open",
"tags": [
"super",
"awesome"
],
"title": "Test case",
"updated_at": null,
"updated_by": null
},
"coreMigrationVersion": "8.5.0",
"id": "063d5820-1284-11ed-81af-63a2bdfb2bf9",
"migrationVersion": {
"cases": "8.5.0"
},
"references": [],
"type": "cases",
"updated_at": "2022-08-02T16:56:16.808Z",
"version": "WzE3MywxXQ=="
}
{
"attributes": {
"action": "create",
"created_at": "2022-08-02T16:56:17.195Z",
"created_by": {
"email": null,
"full_name": null,
"username": "elastic"
},
"owner": "cases",
"payload": {
"assignees": [
{
"uid": "abc"
}
],
"connector": {
"fields": null,
"name": "none",
"type": ".none"
},
"description": "a case description",
"owner": "cases",
"settings": {
"syncAlerts": false
},
"severity": "low",
"status": "open",
"tags": [
"super",
"awesome"
],
"title": "Test case"
},
"type": "create_case"
},
"coreMigrationVersion": "8.5.0",
"id": "06794fb0-1284-11ed-81af-63a2bdfb2bf9",
"migrationVersion": {
"cases-user-actions": "8.5.0"
},
"references": [
{
"id": "063d5820-1284-11ed-81af-63a2bdfb2bf9",
"name": "associated-cases",
"type": "cases"
}
],
"score": null,
"sort": [
1659459377195,
334
],
"type": "cases-user-actions",
"updated_at": "2022-08-02T16:56:17.195Z",
"version": "WzE3NCwxXQ=="
}

View file

@ -5,9 +5,10 @@
* 2.0. * 2.0.
*/ */
import { CaseConnector, CasePostRequest } from '@kbn/cases-plugin/common/api';
import uuid from 'uuid'; import uuid from 'uuid';
export function generateRandomCaseWithoutConnector() { export function generateRandomCaseWithoutConnector(): CasePostRequest {
return { return {
title: 'random-' + uuid.v4(), title: 'random-' + uuid.v4(),
tags: ['test', uuid.v4()], tags: ['test', uuid.v4()],
@ -17,7 +18,7 @@ export function generateRandomCaseWithoutConnector() {
name: 'none', name: 'none',
type: '.none', type: '.none',
fields: null, fields: null,
}, } as CaseConnector,
settings: { settings: {
syncAlerts: false, syncAlerts: false,
}, },