[Cases] Delete users actions and add audit log (#145632)

This PR deletes users actions when the case is deleted. It makes a few
improvements:

- Uses point in time finder for the different case entities when
deleting instead of requesting a single page of 10k results
- Refactors the User actions class to receive the
`unsecuredSavedObjectClient` as a parameter when initializing the class
- Adds the audit log message when a user action is created
- Leverages the bulk delete method from the saved object core library

Fixes: https://github.com/elastic/kibana/issues/143657
https://github.com/elastic/kibana/issues/145124

<details><summary>User actions removed when deleting cases</summary>


https://user-images.githubusercontent.com/56361221/204568129-525e176c-84af-4197-8f80-fc0801676226.mov


</details>

## Example Audit Log Messages

```
[2022-11-29T10:10:32.365-05:00][INFO ][plugins.security.audit.ecs] User created case id: f7138410-6ff7-11ed-860b-4fd7293e59c1 - user action id: f73256b0-6ff7-11ed-860b-4fd7293e59c1
[2022-11-29T10:11:08.716-05:00][INFO ][plugins.security.audit.ecs] User assigned uids: [u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0] to case id: f7138410-6ff7-11ed-860b-4fd7293e59c1 - user action id: 0cf9e670-6ff8-11ed-860b-4fd7293e59c1
```

## Testing

To view the audit log message add these fields to your `kibana.dev.yml`
file

```
xpack.security.audit.enabled: true
xpack.security.audit.appender.type: 'console'
xpack.security.audit.appender.layout.type: 'pattern'
xpack.security.audit.appender.layout.highlight: true
```

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Jonathan Buttner 2022-12-07 16:22:06 -05:00 committed by GitHub
parent 8f71351a94
commit f0777b3bc1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 2691 additions and 1042 deletions

View file

@ -34,9 +34,6 @@ export const Actions = {
push_to_service: 'push_to_service',
} as const;
export type ActionOperationKeys = keyof typeof Actions;
export type ActionOperationValues = typeof Actions[ActionOperationKeys];
/* 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
*/

View file

@ -87,7 +87,6 @@ export async function deleteAll(
});
await userActionService.bulkCreateAttachmentDeletion({
unsecuredSavedObjectsClient,
caseId: caseID,
attachments: comments.saved_objects.map((comment) => ({
id: comment.id,
@ -154,7 +153,6 @@ export async function deleteComment(
await userActionService.createUserAction({
type: ActionTypes.comment,
action: Actions.delete,
unsecuredSavedObjectsClient,
caseId: id,
attachmentId: attachmentID,
payload: { attachment: { ...myComment.attributes } },

View file

@ -40,7 +40,6 @@ export const create = async (
clientArgs: CasesClientArgs
): Promise<CaseResponse> => {
const {
unsecuredSavedObjectsClient,
services: { caseService, userActionService, licensingService, notificationService },
user,
logger,
@ -105,7 +104,6 @@ export const create = async (
await userActionService.createUserAction({
type: ActionTypes.create_case,
unsecuredSavedObjectsClient,
caseId: newCase.id,
user,
payload: {

View file

@ -5,11 +5,13 @@
* 2.0.
*/
import pMap from 'p-map';
import { Boom } from '@hapi/boom';
import type { SavedObjectsFindResponse } from '@kbn/core/server';
import type { CommentAttributes } from '../../../common/api';
import { MAX_CONCURRENT_SEARCHES } from '../../../common/constants';
import type { SavedObjectsBulkDeleteObject } from '@kbn/core/server';
import {
CASE_COMMENT_SAVED_OBJECT,
CASE_SAVED_OBJECT,
CASE_USER_ACTION_SAVED_OBJECT,
} from '../../../common/constants';
import type { CasesClientArgs } from '..';
import { createCaseError } from '../../common/error';
import type { OwnerEntity } from '../../authorization';
@ -23,7 +25,6 @@ import { Operations } from '../../authorization';
export async function deleteCases(ids: string[], clientArgs: CasesClientArgs): Promise<void> {
const {
unsecuredSavedObjectsClient,
user,
services: { caseService, attachmentService, userActionService },
logger,
authorization,
@ -49,56 +50,27 @@ export async function deleteCases(ids: string[], clientArgs: CasesClientArgs): P
entities: Array.from(entities.values()),
});
const deleteCasesMapper = async (id: string) =>
caseService.deleteCase({
id,
refresh: false,
});
// Ensuring we don't too many concurrent deletions running.
await pMap(ids, deleteCasesMapper, {
concurrency: MAX_CONCURRENT_SEARCHES,
});
const getCommentsMapper = async (id: string) =>
caseService.getAllCaseComments({
id,
});
// Ensuring we don't too many concurrent get running.
const comments = await pMap(ids, getCommentsMapper, {
concurrency: MAX_CONCURRENT_SEARCHES,
});
/**
* This is a nested pMap.Mapper.
* Each element of the comments array contains all comments of a particular case.
* For that reason we need first to create a map that iterate over all cases
* and return a pMap that deletes the comments for that case
*/
const deleteCommentsMapper = async (commentRes: SavedObjectsFindResponse<CommentAttributes>) =>
pMap(commentRes.saved_objects, (comment) =>
attachmentService.delete({
unsecuredSavedObjectsClient,
attachmentId: comment.id,
refresh: false,
})
);
// Ensuring we don't too many concurrent deletions running.
await pMap(comments, deleteCommentsMapper, {
concurrency: MAX_CONCURRENT_SEARCHES,
});
await userActionService.bulkCreateCaseDeletion({
const attachmentIds = await attachmentService.getAttachmentIdsForCases({
caseIds: ids,
unsecuredSavedObjectsClient,
cases: cases.saved_objects.map((caseInfo) => ({
id: caseInfo.id,
owner: caseInfo.attributes.owner,
connectorId: caseInfo.attributes.connector.id,
})),
user,
});
const userActionIds = await userActionService.getUserActionIdsForCases(ids);
const bulkDeleteEntities: SavedObjectsBulkDeleteObject[] = [
...ids.map((id) => ({ id, type: CASE_SAVED_OBJECT })),
...attachmentIds.map((id) => ({ id, type: CASE_COMMENT_SAVED_OBJECT })),
...userActionIds.map((id) => ({ id, type: CASE_USER_ACTION_SAVED_OBJECT })),
];
await caseService.bulkDeleteCaseEntities({
entities: bulkDeleteEntities,
options: { refresh: 'wait_for' },
});
await userActionService.bulkAuditLogCaseDeletion(
cases.saved_objects.map((caseInfo) => caseInfo.id)
);
} catch (error) {
throw createCaseError({
message: `Failed to delete cases ids: ${JSON.stringify(ids)}: ${error}`,

View file

@ -259,7 +259,6 @@ export const push = async (
if (shouldMarkAsClosed) {
await userActionService.createUserAction({
type: ActionTypes.status,
unsecuredSavedObjectsClient,
payload: { status: CaseStatuses.closed },
user,
caseId,
@ -274,7 +273,6 @@ export const push = async (
await userActionService.createUserAction({
type: ActionTypes.pushed,
unsecuredSavedObjectsClient,
payload: { externalService },
user,
caseId,

View file

@ -305,7 +305,6 @@ export const update = async (
clientArgs: CasesClientArgs
): Promise<CasesResponse> => {
const {
unsecuredSavedObjectsClient,
services: {
caseService,
userActionService,
@ -446,7 +445,6 @@ export const update = async (
}, [] as CaseResponse[]);
await userActionService.bulkCreateUpdateCase({
unsecuredSavedObjectsClient,
originalCases: myCases.saved_objects,
updatedCases: updatedCases.saved_objects,
user,

View file

@ -14,7 +14,11 @@ import type {
IBasePath,
} from '@kbn/core/server';
import { SECURITY_EXTENSION_ID } from '@kbn/core-saved-objects-server';
import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/server';
import type {
AuditLogger,
SecurityPluginSetup,
SecurityPluginStart,
} from '@kbn/security-plugin/server';
import type { PluginStartContract as FeaturesPluginStart } from '@kbn/features-plugin/server';
import type { PluginStartContract as ActionsPluginStart } from '@kbn/actions-plugin/server';
import type { LensServerPluginSetup } from '@kbn/lens-plugin/server';
@ -119,6 +123,7 @@ export class CasesClientFactory {
unsecuredSavedObjectsClient,
esClient: scopedClusterClient,
request,
auditLogger,
});
const userInfo = await this.getUserInfo(request);
@ -149,10 +154,12 @@ export class CasesClientFactory {
unsecuredSavedObjectsClient,
esClient,
request,
auditLogger,
}: {
unsecuredSavedObjectsClient: SavedObjectsClientContract;
esClient: ElasticsearchClient;
request: KibanaRequest;
auditLogger: AuditLogger;
}): CasesServices {
this.validateInitialization();
@ -190,10 +197,12 @@ export class CasesClientFactory {
caseService,
caseConfigureService: new CaseConfigureService(this.logger),
connectorMappingsService: new ConnectorMappingsService(this.logger),
userActionService: new CaseUserActionService(
this.logger,
this.options.persistableStateAttachmentTypeRegistry
),
userActionService: new CaseUserActionService({
log: this.logger,
persistableStateAttachmentTypeRegistry: this.options.persistableStateAttachmentTypeRegistry,
unsecuredSavedObjectsClient,
auditLogger,
}),
attachmentService,
licensingService,
notificationService,

View file

@ -18,7 +18,6 @@ export class Connectors extends SingleCaseBaseHandler {
public async compute(): Promise<SingleCaseMetricsResponse> {
const {
unsecuredSavedObjectsClient,
authorization,
services: { userActionService },
logger,
@ -29,7 +28,6 @@ export class Connectors extends SingleCaseBaseHandler {
);
const uniqueConnectors = await userActionService.getUniqueConnectors({
unsecuredSavedObjectsClient,
caseId: this.caseId,
filter: authorizationFilter,
});

View file

@ -26,7 +26,6 @@ export class Lifespan extends SingleCaseBaseHandler {
public async compute(): Promise<SingleCaseMetricsResponse> {
const {
unsecuredSavedObjectsClient,
authorization,
services: { userActionService },
logger,
@ -49,7 +48,6 @@ export class Lifespan extends SingleCaseBaseHandler {
);
const statusUserActions = await userActionService.findStatusChanges({
unsecuredSavedObjectsClient,
caseId: this.caseId,
filter: authorizationFilter,
});

View file

@ -18,17 +18,13 @@ export const get = async (
clientArgs: CasesClientArgs
): Promise<CaseUserActionsResponse> => {
const {
unsecuredSavedObjectsClient,
services: { userActionService },
logger,
authorization,
} = clientArgs;
try {
const userActions = await userActionService.getAll({
unsecuredSavedObjectsClient,
caseId,
});
const userActions = await userActionService.getAll(caseId);
await authorization.ensureAuthorized({
entities: userActions.saved_objects.map((userAction) => ({

View file

@ -185,7 +185,6 @@ export class CaseCommentModel {
await this.params.services.userActionService.createUserAction({
type: ActionTypes.comment,
action: Actions.update,
unsecuredSavedObjectsClient: this.params.unsecuredSavedObjectsClient,
caseId: this.caseInfo.id,
attachmentId: comment.id,
payload: { attachment: queryRestAttributes },
@ -339,7 +338,6 @@ export class CaseCommentModel {
await this.params.services.userActionService.createUserAction({
type: ActionTypes.comment,
action: Actions.create,
unsecuredSavedObjectsClient: this.params.unsecuredSavedObjectsClient,
caseId: this.caseInfo.id,
attachmentId: comment.id,
payload: {
@ -352,7 +350,6 @@ export class CaseCommentModel {
private async bulkCreateCommentUserAction(attachments: Array<{ id: string } & CommentRequest>) {
await this.params.services.userActionService.bulkCreateAttachmentCreation({
unsecuredSavedObjectsClient: this.params.unsecuredSavedObjectsClient,
caseId: this.caseInfo.id,
attachments: attachments.map(({ id, ...attachment }) => ({
id,

View file

@ -100,6 +100,43 @@ export class AttachmentService {
private readonly persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry
) {}
public async getAttachmentIdsForCases({
caseIds,
unsecuredSavedObjectsClient,
}: {
caseIds: string[];
unsecuredSavedObjectsClient: SavedObjectsClientContract;
}) {
try {
this.log.debug(`Attempting to retrieve attachments associated with cases: [${caseIds}]`);
const finder = unsecuredSavedObjectsClient.createPointInTimeFinder({
type: CASE_COMMENT_SAVED_OBJECT,
hasReference: caseIds.map((id) => ({ id, type: CASE_SAVED_OBJECT })),
sortField: 'created_at',
sortOrder: 'asc',
/**
* We only care about the ids so to reduce the data returned we should limit the fields in the response. Core
* doesn't support retrieving no fields (id would always be returned anyway) so to limit it we'll only request
* the owner even though we don't need it.
*/
fields: ['owner'],
perPage: MAX_DOCS_PER_PAGE,
});
const ids: string[] = [];
for await (const attachmentSavedObject of finder.find()) {
ids.push(...attachmentSavedObject.saved_objects.map((attachment) => attachment.id));
}
return ids;
} catch (error) {
this.log.error(`Error retrieving attachments associated with cases: [${caseIds}]: ${error}`);
throw error;
}
}
public async countAlertsAttachedToCase(
params: AlertsAttachedToCaseArgs
): Promise<number | undefined> {

View file

@ -15,6 +15,8 @@ import type {
SavedObjectsUpdateResponse,
SavedObjectsResolveResponse,
SavedObjectsFindOptions,
SavedObjectsBulkDeleteObject,
SavedObjectsBulkDeleteOptions,
} from '@kbn/core/server';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
@ -308,6 +310,21 @@ export class CasesService {
}
}
public async bulkDeleteCaseEntities({
entities,
options,
}: {
entities: SavedObjectsBulkDeleteObject[];
options?: SavedObjectsBulkDeleteOptions;
}) {
try {
this.log.debug(`Attempting to bulk delete case entities ${JSON.stringify(entities)}`);
return await this.unsecuredSavedObjectsClient.bulkDelete(entities, options);
} catch (error) {
this.log.error(`Error bulk deleting case entities ${JSON.stringify(entities)}: ${error}`);
}
}
public async getCase({ id: caseId }: GetCaseArgs): Promise<CaseSavedObject> {
try {
this.log.debug(`Attempting to GET case ${caseId}`);

View file

@ -75,16 +75,15 @@ export const connectorMappingsServiceMock = (): ConnectorMappingsServiceMock =>
export const createUserActionServiceMock = (): CaseUserActionServiceMock => {
const service: PublicMethodsOf<CaseUserActionService> = {
bulkCreateCaseDeletion: jest.fn(),
bulkAuditLogCaseDeletion: jest.fn(),
bulkCreateUpdateCase: jest.fn(),
bulkCreateAttachmentDeletion: jest.fn(),
bulkCreateAttachmentCreation: jest.fn(),
createUserAction: jest.fn(),
create: jest.fn(),
getAll: jest.fn(),
bulkCreate: jest.fn(),
findStatusChanges: jest.fn(),
getUniqueConnectors: jest.fn(),
getUserActionIdsForCases: jest.fn(),
};
// the cast here is required because jest.Mocked tries to include private members and would throw an error
@ -117,6 +116,7 @@ export const createAttachmentServiceMock = (): AttachmentServiceMock => {
getCaseCommentStats: jest.fn(),
valueCountAlertsAttachedToCase: jest.fn(),
executeCaseAggregations: jest.fn(),
getAttachmentIdsForCases: jest.fn(),
};
// the cast here is required because jest.Mocked tries to include private members and would throw an error

View file

@ -0,0 +1,116 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`UserActionAuditLogger logs add user action as event.type change 1`] = `
Array [
Object {
"event": Object {
"action": "action",
"category": Array [
"database",
],
"type": Array [
"change",
],
},
"kibana": Object {
"saved_object": Object {
"id": "123",
"type": "type",
},
},
"message": "id: idParam",
},
]
`;
exports[`UserActionAuditLogger logs create user action as event.type creation 1`] = `
Array [
Object {
"event": Object {
"action": "action",
"category": Array [
"database",
],
"type": Array [
"creation",
],
},
"kibana": Object {
"saved_object": Object {
"id": "123",
"type": "type",
},
},
"message": "id: idParam",
},
]
`;
exports[`UserActionAuditLogger logs delete user action as event.type deletion 1`] = `
Array [
Object {
"event": Object {
"action": "action",
"category": Array [
"database",
],
"type": Array [
"deletion",
],
},
"kibana": Object {
"saved_object": Object {
"id": "123",
"type": "type",
},
},
"message": "id: idParam",
},
]
`;
exports[`UserActionAuditLogger logs push_to_service user action as event.type creation 1`] = `
Array [
Object {
"event": Object {
"action": "action",
"category": Array [
"database",
],
"type": Array [
"creation",
],
},
"kibana": Object {
"saved_object": Object {
"id": "123",
"type": "type",
},
},
"message": "id: idParam",
},
]
`;
exports[`UserActionAuditLogger logs update user action as event.type change 1`] = `
Array [
Object {
"event": Object {
"action": "action",
"category": Array [
"database",
],
"type": Array [
"change",
],
},
"kibana": Object {
"saved_object": Object {
"id": "123",
"type": "type",
},
},
"message": "id: idParam",
},
]
`;

View file

@ -0,0 +1,70 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CaseUserActionService methods createUserAction create case comment logs a comment user action of action: create 1`] = `
Array [
Object {
"event": Object {
"action": "case_user_action_create_comment",
"category": Array [
"database",
],
"type": Array [
"creation",
],
},
"kibana": Object {
"saved_object": Object {
"id": "test-id",
"type": "cases-comments",
},
},
"message": "User created comment id: test-id for case id: 123 - user action id: created_user_action_id",
},
]
`;
exports[`CaseUserActionService methods createUserAction create case comment logs a comment user action of action: delete 1`] = `
Array [
Object {
"event": Object {
"action": "case_user_action_delete_comment",
"category": Array [
"database",
],
"type": Array [
"deletion",
],
},
"kibana": Object {
"saved_object": Object {
"id": "test-id",
"type": "cases-comments",
},
},
"message": "User deleted comment id: test-id for case id: 123 - user action id: created_user_action_id",
},
]
`;
exports[`CaseUserActionService methods createUserAction create case comment logs a comment user action of action: update 1`] = `
Array [
Object {
"event": Object {
"action": "case_user_action_update_comment",
"category": Array [
"database",
],
"type": Array [
"change",
],
},
"kibana": Object {
"saved_object": Object {
"id": "test-id",
"type": "cases-comments",
},
},
"message": "User changed comment id: test-id for case id: 123 - user action id: created_user_action_id",
},
]
`;

View file

@ -19,9 +19,10 @@ import { ActionTypes, NONE_CONNECTOR_ID } from '../../../common/api';
import type {
BuilderDeps,
BuilderParameters,
BuilderReturnValue,
CommonBuilderArguments,
SavedObjectParameters,
UserActionParameters,
UserActionEvent,
} from './types';
import type { PersistableStateAttachmentTypeRegistry } from '../../attachment_framework/persistable_state_registry';
@ -98,7 +99,7 @@ export abstract class UserActionBuilder {
attachmentId,
connectorId,
type,
}: CommonBuilderArguments): BuilderReturnValue => {
}: CommonBuilderArguments): SavedObjectParameters => {
return {
attributes: {
...this.getCommonUserActionAttributes({ user, owner }),
@ -121,5 +122,5 @@ export abstract class UserActionBuilder {
public abstract build<T extends keyof BuilderParameters>(
args: UserActionParameters<T>
): BuilderReturnValue;
): UserActionEvent;
}

View file

@ -0,0 +1,49 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Actions } from '../../../common/api';
import type { AuditLogger } from '@kbn/security-plugin/server';
import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks';
import { UserActionAuditLogger } from './audit_logger';
import type { EventDetails } from './types';
describe('UserActionAuditLogger', () => {
let mockLogger: jest.Mocked<AuditLogger>;
beforeEach(() => {
mockLogger = auditLoggerMock.create();
});
it.each([
[Actions.add, 'change'],
[Actions.create, 'creation'],
[Actions.delete, 'deletion'],
[Actions.push_to_service, 'creation'],
[Actions.update, 'change'],
])('logs %s user action as event.type %s', (action, type) => {
const eventDetails: EventDetails = {
getMessage: (id?: string) => `id: ${id}`,
action,
descriptiveAction: 'action',
savedObjectId: '123',
savedObjectType: 'type',
};
const logger = new UserActionAuditLogger(mockLogger);
logger.log(eventDetails, 'idParam');
expect(mockLogger.log.mock.calls[0]).toMatchSnapshot();
});
it('does not call the internal audit logger when the event details are undefined', () => {
const logger = new UserActionAuditLogger(mockLogger);
logger.log();
expect(mockLogger.log).not.toBeCalled();
});
});

View file

@ -0,0 +1,44 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { EcsEventType } from '@kbn/logging';
import type { AuditLogger } from '@kbn/security-plugin/server';
import type { UserAction as Action } from '../../../common/api';
import type { EventDetails } from './types';
const actionsToEcsType: Record<Action, EcsEventType> = {
add: 'change',
delete: 'deletion',
create: 'creation',
push_to_service: 'creation',
update: 'change',
};
export class UserActionAuditLogger {
constructor(private readonly auditLogger: AuditLogger) {}
public log(event?: EventDetails, storedUserActionId?: string) {
if (!event) {
return;
}
this.auditLogger.log({
message: event.getMessage(storedUserActionId),
event: {
action: event.descriptiveAction,
category: ['database'],
type: [actionsToEcsType[event.action]],
},
kibana: {
saved_object: {
type: event.savedObjectType,
id: event.savedObjectId,
},
},
});
}
}

View file

@ -5,18 +5,54 @@
* 2.0.
*/
import { CASE_SAVED_OBJECT } from '../../../../common/constants';
import type { UserAction } from '../../../../common/api';
import { ActionTypes, Actions } from '../../../../common/api';
import { UserActionBuilder } from '../abstract_builder';
import type { UserActionParameters, BuilderReturnValue } from '../types';
import type { EventDetails, UserActionParameters, UserActionEvent } from '../types';
export class AssigneesUserActionBuilder extends UserActionBuilder {
build(args: UserActionParameters<'assignees'>): BuilderReturnValue {
return this.buildCommonUserAction({
build(args: UserActionParameters<'assignees'>): UserActionEvent {
const action = args.action ?? Actions.add;
const soParams = this.buildCommonUserAction({
...args,
action: args.action ?? Actions.add,
action,
valueKey: 'assignees',
value: args.payload.assignees,
type: ActionTypes.assignees,
});
const uids = args.payload.assignees.map((assignee) => assignee.uid);
const verbMessage = getVerbMessage(action, uids);
const getMessage = (id?: string) =>
`User ${verbMessage} case id: ${args.caseId} - user action id: ${id}`;
const event: EventDetails = {
getMessage,
action,
descriptiveAction: `case_user_action_${action}_case_assignees`,
savedObjectId: args.caseId,
savedObjectType: CASE_SAVED_OBJECT,
};
return {
parameters: soParams,
eventDetails: event,
};
}
}
const getVerbMessage = (action: UserAction, uids: string[]) => {
const uidText = `uids: [${uids}]`;
switch (action) {
case 'add':
return `assigned ${uidText} to`;
case 'delete':
return `unassigned ${uidText} from`;
default:
return `changed ${uidText} for`;
}
};

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 type { UserAction as Action } from '../../../../common/api';
const actionsToVerbs: Record<Action, string> = {
add: 'added',
delete: 'deleted',
create: 'created',
push_to_service: 'pushed',
update: 'changed',
};
export const getPastTenseVerb = (action: Action): string => actionsToVerbs[action];

View file

@ -6,15 +6,17 @@
*/
import { uniqBy } from 'lodash';
import { CASE_COMMENT_SAVED_OBJECT } from '../../../../common/constants';
import { extractPersistableStateReferencesFromSO } from '../../../attachment_framework/so_references';
import type { CommentUserAction } from '../../../../common/api';
import { ActionTypes, Actions } from '../../../../common/api';
import { UserActionBuilder } from '../abstract_builder';
import type { UserActionParameters, BuilderReturnValue } from '../types';
import type { EventDetails, UserActionParameters, UserActionEvent } from '../types';
import { getAttachmentSOExtractor } from '../../so_references';
import { getPastTenseVerb } from './audit_logger_utils';
export class CommentUserActionBuilder extends UserActionBuilder {
build(args: UserActionParameters<'comment'>): BuilderReturnValue {
build(args: UserActionParameters<'comment'>): UserActionEvent {
const soExtractor = getAttachmentSOExtractor(args.payload.attachment);
const { transformedFields, references: refsWithExternalRefId } =
soExtractor.extractFieldsToReferences<CommentUserAction['payload']['comment']>({
@ -26,20 +28,46 @@ export class CommentUserActionBuilder extends UserActionBuilder {
persistableStateAttachmentTypeRegistry: this.persistableStateAttachmentTypeRegistry,
});
const action = args.action ?? Actions.update;
const commentUserAction = this.buildCommonUserAction({
...args,
action: args.action ?? Actions.update,
action,
valueKey: 'comment',
value: { ...transformedFields, ...extractedAttributes },
type: ActionTypes.comment,
});
return {
const parameters = {
...commentUserAction,
references: uniqBy(
[...commentUserAction.references, ...refsWithExternalRefId, ...extractedReferences],
'id'
),
};
const verb = getPastTenseVerb(action);
const getMessage = (id?: string) =>
`User ${verb} comment id: ${commentId(args.attachmentId)} for case id: ${
args.caseId
} - user action id: ${id}`;
const eventDetails: EventDetails = {
getMessage,
action,
descriptiveAction: `case_user_action_${action}_comment`,
savedObjectId: args.attachmentId ?? args.caseId,
savedObjectType: CASE_COMMENT_SAVED_OBJECT,
};
return {
parameters,
eventDetails,
};
}
}
const commentId = (id?: string) => {
return id ? id : 'unknown';
};

View file

@ -5,19 +5,38 @@
* 2.0.
*/
import { CASE_SAVED_OBJECT } from '../../../../common/constants';
import { Actions, ActionTypes } from '../../../../common/api';
import { UserActionBuilder } from '../abstract_builder';
import type { UserActionParameters, BuilderReturnValue } from '../types';
import type { EventDetails, UserActionParameters, UserActionEvent } from '../types';
export class ConnectorUserActionBuilder extends UserActionBuilder {
build(args: UserActionParameters<'connector'>): BuilderReturnValue {
return this.buildCommonUserAction({
build(args: UserActionParameters<'connector'>): UserActionEvent {
const action = Actions.update;
const parameters = this.buildCommonUserAction({
...args,
action: Actions.update,
action,
valueKey: 'connector',
value: this.extractConnectorId(args.payload.connector),
type: ActionTypes.connector,
connectorId: args.payload.connector.id,
});
const getMessage = (id?: string) =>
`User changed the case connector to id: ${args.payload.connector.id} for case id: ${args.caseId} - user action id: ${id}`;
const eventDetails: EventDetails = {
getMessage,
action,
descriptiveAction: 'case_user_action_update_case_connector',
savedObjectId: args.caseId,
savedObjectType: CASE_SAVED_OBJECT,
};
return {
parameters,
eventDetails,
};
}
}

View file

@ -5,18 +5,21 @@
* 2.0.
*/
import { CASE_SAVED_OBJECT } from '../../../../common/constants';
import { Actions, ActionTypes, CaseStatuses } from '../../../../common/api';
import { UserActionBuilder } from '../abstract_builder';
import type { UserActionParameters, BuilderReturnValue } from '../types';
import type { EventDetails, UserActionParameters, UserActionEvent } from '../types';
export class CreateCaseUserActionBuilder extends UserActionBuilder {
build(args: UserActionParameters<'create_case'>): BuilderReturnValue {
build(args: UserActionParameters<'create_case'>): UserActionEvent {
const { payload, caseId, owner, user } = args;
const action = Actions.create;
const connectorWithoutId = this.extractConnectorId(payload.connector);
return {
const parameters = {
attributes: {
...this.getCommonUserActionAttributes({ user, owner }),
action: Actions.create,
action,
payload: { ...payload, connector: connectorWithoutId, status: CaseStatuses.open },
type: ActionTypes.create_case,
},
@ -25,5 +28,20 @@ export class CreateCaseUserActionBuilder extends UserActionBuilder {
...this.createConnectorReference(payload.connector.id),
],
};
const getMessage = (id?: string) => `User created case id: ${caseId} - user action id: ${id}`;
const eventDetails: EventDetails = {
getMessage,
action,
descriptiveAction: 'case_user_action_create_case',
savedObjectId: caseId,
savedObjectType: CASE_SAVED_OBJECT,
};
return {
parameters,
eventDetails,
};
}
}

View file

@ -5,17 +5,26 @@
* 2.0.
*/
import { CASE_SAVED_OBJECT } from '../../../../common/constants';
import type { UserAction } from '../../../../common/api';
import { Actions, ActionTypes } from '../../../../common/api';
import { UserActionBuilder } from '../abstract_builder';
import type { UserActionParameters, BuilderReturnValue } from '../types';
import type {
SavedObjectParameters,
EventDetails,
UserActionParameters,
UserActionEvent,
} from '../types';
export class DeleteCaseUserActionBuilder extends UserActionBuilder {
build(args: UserActionParameters<'delete_case'>): BuilderReturnValue {
build(args: UserActionParameters<'delete_case'>): UserActionEvent {
const { caseId, owner, user, connectorId } = args;
return {
const action = Actions.delete;
const parameters: SavedObjectParameters = {
attributes: {
...this.getCommonUserActionAttributes({ user, owner }),
action: Actions.delete,
action,
payload: {},
type: ActionTypes.delete_case,
},
@ -24,5 +33,24 @@ export class DeleteCaseUserActionBuilder extends UserActionBuilder {
...this.createConnectorReference(connectorId ?? null),
],
};
return {
parameters,
eventDetails: createDeleteEvent({ caseId, action }),
};
}
}
export const createDeleteEvent = ({
caseId,
action,
}: {
caseId: string;
action: UserAction;
}): EventDetails => ({
getMessage: () => `User deleted case id: ${caseId}`,
action,
descriptiveAction: 'case_user_action_delete_case',
savedObjectId: caseId,
savedObjectType: CASE_SAVED_OBJECT,
});

View file

@ -5,18 +5,37 @@
* 2.0.
*/
import { CASE_SAVED_OBJECT } from '../../../../common/constants';
import { Actions, ActionTypes } from '../../../../common/api';
import { UserActionBuilder } from '../abstract_builder';
import type { UserActionParameters, BuilderReturnValue } from '../types';
import type { EventDetails, UserActionParameters, UserActionEvent } from '../types';
export class DescriptionUserActionBuilder extends UserActionBuilder {
build(args: UserActionParameters<'description'>): BuilderReturnValue {
return this.buildCommonUserAction({
build(args: UserActionParameters<'description'>): UserActionEvent {
const action = Actions.update;
const parameters = this.buildCommonUserAction({
...args,
action: Actions.update,
action,
valueKey: 'description',
type: ActionTypes.description,
value: args.payload.description,
});
const getMessage = (id?: string) =>
`User updated the description for case id: ${args.caseId} - user action id: ${id}`;
const eventDetails: EventDetails = {
getMessage,
action,
descriptiveAction: 'case_user_action_update_case_description',
savedObjectId: args.caseId,
savedObjectType: CASE_SAVED_OBJECT,
};
return {
parameters,
eventDetails,
};
}
}

View file

@ -5,19 +5,38 @@
* 2.0.
*/
import { CASE_SAVED_OBJECT } from '../../../../common/constants';
import { Actions, ActionTypes } from '../../../../common/api';
import { UserActionBuilder } from '../abstract_builder';
import type { UserActionParameters, BuilderReturnValue } from '../types';
import type { EventDetails, UserActionParameters, UserActionEvent } from '../types';
export class PushedUserActionBuilder extends UserActionBuilder {
build(args: UserActionParameters<'pushed'>): BuilderReturnValue {
return this.buildCommonUserAction({
build(args: UserActionParameters<'pushed'>): UserActionEvent {
const action = Actions.push_to_service;
const parameters = this.buildCommonUserAction({
...args,
action: Actions.push_to_service,
action,
valueKey: 'externalService',
value: this.extractConnectorIdFromExternalService(args.payload.externalService),
type: ActionTypes.pushed,
connectorId: args.payload.externalService.connector_id,
});
const getMessage = (id?: string) =>
`User pushed case id: ${args.caseId} to an external service with connector id: ${args.payload.externalService.connector_id} - user action id: ${id}`;
const eventDetails: EventDetails = {
getMessage,
action,
descriptiveAction: 'case_user_action_pushed_case',
savedObjectId: args.caseId,
savedObjectType: CASE_SAVED_OBJECT,
};
return {
parameters,
eventDetails,
};
}
}

View file

@ -5,18 +5,37 @@
* 2.0.
*/
import { CASE_SAVED_OBJECT } from '../../../../common/constants';
import { Actions, ActionTypes } from '../../../../common/api';
import { UserActionBuilder } from '../abstract_builder';
import type { UserActionParameters, BuilderReturnValue } from '../types';
import type { EventDetails, UserActionParameters, UserActionEvent } from '../types';
export class SettingsUserActionBuilder extends UserActionBuilder {
build(args: UserActionParameters<'settings'>): BuilderReturnValue {
return this.buildCommonUserAction({
build(args: UserActionParameters<'settings'>): UserActionEvent {
const action = Actions.update;
const parameters = this.buildCommonUserAction({
...args,
action: Actions.update,
action,
valueKey: 'settings',
value: args.payload.settings,
type: ActionTypes.settings,
});
const getMessage = (id?: string) =>
`User updated the settings for case id: ${args.caseId} - user action id: ${id}`;
const eventDetails: EventDetails = {
getMessage,
action,
descriptiveAction: 'case_user_action_update_case_settings',
savedObjectId: args.caseId,
savedObjectType: CASE_SAVED_OBJECT,
};
return {
parameters,
eventDetails,
};
}
}

View file

@ -5,18 +5,37 @@
* 2.0.
*/
import { CASE_SAVED_OBJECT } from '../../../../common/constants';
import { Actions, ActionTypes } from '../../../../common/api';
import { UserActionBuilder } from '../abstract_builder';
import type { UserActionParameters, BuilderReturnValue } from '../types';
import type { EventDetails, UserActionParameters, UserActionEvent } from '../types';
export class SeverityUserActionBuilder extends UserActionBuilder {
build(args: UserActionParameters<'severity'>): BuilderReturnValue {
return this.buildCommonUserAction({
build(args: UserActionParameters<'severity'>): UserActionEvent {
const action = Actions.update;
const parameters = this.buildCommonUserAction({
...args,
action: Actions.update,
action,
valueKey: 'severity',
value: args.payload.severity,
type: ActionTypes.severity,
});
const getMessage = (id?: string) =>
`User updated the severity for case id: ${args.caseId} - user action id: ${id}`;
const eventDetails: EventDetails = {
getMessage,
action,
descriptiveAction: 'case_user_action_update_case_severity',
savedObjectId: args.caseId,
savedObjectType: CASE_SAVED_OBJECT,
};
return {
parameters,
eventDetails,
};
}
}

View file

@ -5,18 +5,37 @@
* 2.0.
*/
import { CASE_SAVED_OBJECT } from '../../../../common/constants';
import { Actions, ActionTypes } from '../../../../common/api';
import { UserActionBuilder } from '../abstract_builder';
import type { UserActionParameters, BuilderReturnValue } from '../types';
import type { EventDetails, UserActionParameters, UserActionEvent } from '../types';
export class StatusUserActionBuilder extends UserActionBuilder {
build(args: UserActionParameters<'status'>): BuilderReturnValue {
return this.buildCommonUserAction({
build(args: UserActionParameters<'status'>): UserActionEvent {
const action = Actions.update;
const parameters = this.buildCommonUserAction({
...args,
action: Actions.update,
action,
valueKey: 'status',
value: args.payload.status,
type: ActionTypes.status,
});
const getMessage = (id?: string) =>
`User updated the status for case id: ${args.caseId} - user action id: ${id}`;
const eventDetails: EventDetails = {
getMessage,
action,
descriptiveAction: 'case_user_action_update_case_status',
savedObjectId: args.caseId,
savedObjectType: CASE_SAVED_OBJECT,
};
return {
parameters,
eventDetails,
};
}
}

View file

@ -5,18 +5,53 @@
* 2.0.
*/
import { CASE_SAVED_OBJECT } from '../../../../common/constants';
import type { UserAction } from '../../../../common/api';
import { ActionTypes, Actions } from '../../../../common/api';
import { UserActionBuilder } from '../abstract_builder';
import type { UserActionParameters, BuilderReturnValue } from '../types';
import type { EventDetails, UserActionParameters, UserActionEvent } from '../types';
import { getPastTenseVerb } from './audit_logger_utils';
export class TagsUserActionBuilder extends UserActionBuilder {
build(args: UserActionParameters<'tags'>): BuilderReturnValue {
return this.buildCommonUserAction({
build(args: UserActionParameters<'tags'>): UserActionEvent {
const action = args.action ?? Actions.add;
const parameters = this.buildCommonUserAction({
...args,
action: args.action ?? Actions.add,
valueKey: 'tags',
value: args.payload.tags,
type: ActionTypes.tags,
});
const verb = getPastTenseVerb(action);
const preposition = getPreposition(action);
const getMessage = (id?: string) =>
`User ${verb} tags ${preposition} case id: ${args.caseId} - user action id: ${id}`;
const eventDetails: EventDetails = {
getMessage,
action,
descriptiveAction: `case_user_action_${action}_case_tags`,
savedObjectId: args.caseId,
savedObjectType: CASE_SAVED_OBJECT,
};
return {
parameters,
eventDetails,
};
}
}
const getPreposition = (action: UserAction): string => {
switch (action) {
case Actions.add:
return 'to';
case Actions.delete:
return 'in';
default:
return 'for';
}
};

View file

@ -5,18 +5,37 @@
* 2.0.
*/
import { CASE_SAVED_OBJECT } from '../../../../common/constants';
import { Actions, ActionTypes } from '../../../../common/api';
import { UserActionBuilder } from '../abstract_builder';
import type { BuilderReturnValue, UserActionParameters } from '../types';
import type { EventDetails, UserActionParameters, UserActionEvent } from '../types';
export class TitleUserActionBuilder extends UserActionBuilder {
build(args: UserActionParameters<'title'>): BuilderReturnValue {
return this.buildCommonUserAction({
build(args: UserActionParameters<'title'>): UserActionEvent {
const action = Actions.update;
const parameters = this.buildCommonUserAction({
...args,
action: Actions.update,
action,
valueKey: 'title',
value: args.payload.title,
type: ActionTypes.title,
});
const getMessage = (id?: string) =>
`User updated the title for case id: ${args.caseId} - user action id: ${id}`;
const eventDetails: EventDetails = {
getMessage,
action,
descriptiveAction: 'case_user_action_update_case_title',
savedObjectId: args.caseId,
savedObjectType: CASE_SAVED_OBJECT,
};
return {
parameters,
eventDetails,
};
}
}

View file

@ -11,11 +11,13 @@ 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,
@ -635,17 +637,38 @@ describe('CaseUserActionService', () => {
describe('methods', () => {
let service: CaseUserActionService;
const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
unsecuredSavedObjectsClient.create.mockResolvedValue({
id: 'created_user_action_id',
} as SavedObject);
unsecuredSavedObjectsClient.bulkCreate.mockImplementation(
async (objects: SavedObjectsBulkCreateObject[]) => {
const savedObjects: SavedObject[] = [];
for (let i = 0; i < objects.length; i++) {
savedObjects.push({ id: i } as unknown as SavedObject);
}
return {
saved_objects: savedObjects,
};
}
);
const mockLogger = loggerMock.create();
const commonArgs = {
unsecuredSavedObjectsClient,
caseId: '123',
user: { full_name: 'Elastic User', username: 'elastic', email: 'elastic@elastic.co' },
owner: SECURITY_SOLUTION_OWNER,
};
const mockAuditLogger = auditLoggerMock.create();
beforeEach(() => {
jest.clearAllMocks();
service = new CaseUserActionService(mockLogger, persistableStateAttachmentTypeRegistry);
service = new CaseUserActionService({
unsecuredSavedObjectsClient,
log: mockLogger,
persistableStateAttachmentTypeRegistry,
auditLogger: mockAuditLogger,
});
});
describe('createUserAction', () => {
@ -702,6 +725,38 @@ describe('CaseUserActionService', () => {
);
});
it('logs a create case user action', async () => {
await service.createUserAction({
...commonArgs,
payload: casePayload,
type: ActionTypes.create_case,
});
expect(mockAuditLogger.log).toBeCalledTimes(1);
expect(mockAuditLogger.log.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"event": Object {
"action": "case_user_action_create_case",
"category": Array [
"database",
],
"type": Array [
"creation",
],
},
"kibana": Object {
"saved_object": Object {
"id": "123",
"type": "cases",
},
},
"message": "User created case id: 123 - user action id: created_user_action_id",
},
]
`);
});
describe('status', () => {
it('creates an update status user action', async () => {
await service.createUserAction({
@ -727,6 +782,38 @@ describe('CaseUserActionService', () => {
{ references: [{ id: '123', name: 'associated-cases', type: 'cases' }] }
);
});
it('logs an update status user action', async () => {
await service.createUserAction({
...commonArgs,
payload: { status: CaseStatuses.closed },
type: ActionTypes.status,
});
expect(mockAuditLogger.log).toBeCalledTimes(1);
expect(mockAuditLogger.log.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"event": Object {
"action": "case_user_action_update_case_status",
"category": Array [
"database",
],
"type": Array [
"change",
],
},
"kibana": Object {
"saved_object": Object {
"id": "123",
"type": "cases",
},
},
"message": "User updated the status for case id: 123 - user action id: created_user_action_id",
},
]
`);
});
});
describe('severity', () => {
@ -754,6 +841,38 @@ describe('CaseUserActionService', () => {
{ references: [{ id: '123', name: 'associated-cases', type: 'cases' }] }
);
});
it('logs an update severity user action', async () => {
await service.createUserAction({
...commonArgs,
payload: { severity: CaseSeverity.MEDIUM },
type: ActionTypes.severity,
});
expect(mockAuditLogger.log).toBeCalledTimes(1);
expect(mockAuditLogger.log.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"event": Object {
"action": "case_user_action_update_case_severity",
"category": Array [
"database",
],
"type": Array [
"change",
],
},
"kibana": Object {
"saved_object": Object {
"id": "123",
"type": "cases",
},
},
"message": "User updated the severity for case id: 123 - user action id: created_user_action_id",
},
]
`);
});
});
describe('push', () => {
@ -800,6 +919,38 @@ describe('CaseUserActionService', () => {
}
);
});
it('logs a push user action', async () => {
await service.createUserAction({
...commonArgs,
payload: { externalService },
type: ActionTypes.pushed,
});
expect(mockAuditLogger.log).toBeCalledTimes(1);
expect(mockAuditLogger.log.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"event": Object {
"action": "case_user_action_pushed_case",
"category": Array [
"database",
],
"type": Array [
"creation",
],
},
"kibana": Object {
"saved_object": Object {
"id": "123",
"type": "cases",
},
},
"message": "User pushed case id: 123 to an external service with connector id: 456 - user action id: created_user_action_id",
},
]
`);
});
});
describe('comment', () => {
@ -843,64 +994,77 @@ describe('CaseUserActionService', () => {
);
}
);
it.each([[Actions.create], [Actions.delete], [Actions.update]])(
'logs a comment user action of action: %s',
async (action) => {
await service.createUserAction({
...commonArgs,
type: ActionTypes.comment,
action,
attachmentId: 'test-id',
payload: { attachment: comment },
});
expect(mockAuditLogger.log).toBeCalledTimes(1);
expect(mockAuditLogger.log.mock.calls[0]).toMatchSnapshot();
}
);
});
});
});
describe('bulkCreateCaseDeletion', () => {
it('creates a delete case user action', async () => {
await service.bulkCreateCaseDeletion({
unsecuredSavedObjectsClient,
cases: [
{ id: '1', owner: SECURITY_SOLUTION_OWNER, connectorId: '3' },
{ id: '2', owner: SECURITY_SOLUTION_OWNER, connectorId: '4' },
],
user: commonArgs.user,
});
describe('bulkAuditLogCaseDeletion', () => {
it('logs a delete case audit log message', async () => {
await service.bulkAuditLogCaseDeletion(['1', '2']);
expect(unsecuredSavedObjectsClient.bulkCreate).toHaveBeenCalledWith(
[
{
attributes: {
action: 'delete',
created_at: '2022-01-09T22:00:00.000Z',
created_by: {
email: 'elastic@elastic.co',
full_name: 'Elastic User',
username: 'elastic',
expect(unsecuredSavedObjectsClient.bulkCreate).not.toHaveBeenCalled();
expect(mockAuditLogger.log).toHaveBeenCalledTimes(2);
expect(mockAuditLogger.log.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
Object {
"event": Object {
"action": "case_user_action_delete_case",
"category": Array [
"database",
],
"type": Array [
"deletion",
],
},
type: 'delete_case',
owner: 'securitySolution',
payload: {},
},
references: [
{ id: '1', name: 'associated-cases', type: 'cases' },
{ id: '3', name: 'connectorId', type: 'action' },
],
type: 'cases-user-actions',
},
{
attributes: {
action: 'delete',
created_at: '2022-01-09T22:00:00.000Z',
created_by: {
email: 'elastic@elastic.co',
full_name: 'Elastic User',
username: 'elastic',
"kibana": Object {
"saved_object": Object {
"id": "1",
"type": "cases",
},
},
type: 'delete_case',
owner: 'securitySolution',
payload: {},
"message": "User deleted case id: 1",
},
references: [
{ id: '2', name: 'associated-cases', type: 'cases' },
{ id: '4', name: 'connectorId', type: 'action' },
],
type: 'cases-user-actions',
},
],
{ refresh: undefined }
);
],
Array [
Object {
"event": Object {
"action": "case_user_action_delete_case",
"category": Array [
"database",
],
"type": Array [
"deletion",
],
},
"kibana": Object {
"saved_object": Object {
"id": "2",
"type": "cases",
},
},
"message": "User deleted case id: 2",
},
],
]
`);
});
});
@ -1073,6 +1237,181 @@ describe('CaseUserActionService', () => {
);
});
it('logs the correct user actions when bulk updating cases', async () => {
await service.bulkCreateUpdateCase({
...commonArgs,
originalCases,
updatedCases,
user: commonArgs.user,
});
expect(mockAuditLogger.log).toBeCalledTimes(8);
expect(mockAuditLogger.log.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
Object {
"event": Object {
"action": "case_user_action_update_case_title",
"category": Array [
"database",
],
"type": Array [
"change",
],
},
"kibana": Object {
"saved_object": Object {
"id": "1",
"type": "cases",
},
},
"message": "User updated the title for case id: 1 - user action id: 0",
},
],
Array [
Object {
"event": Object {
"action": "case_user_action_update_case_status",
"category": Array [
"database",
],
"type": Array [
"change",
],
},
"kibana": Object {
"saved_object": Object {
"id": "1",
"type": "cases",
},
},
"message": "User updated the status for case id: 1 - user action id: 1",
},
],
Array [
Object {
"event": Object {
"action": "case_user_action_update_case_connector",
"category": Array [
"database",
],
"type": Array [
"change",
],
},
"kibana": Object {
"saved_object": Object {
"id": "1",
"type": "cases",
},
},
"message": "User changed the case connector to id: 456 for case id: 1 - user action id: 2",
},
],
Array [
Object {
"event": Object {
"action": "case_user_action_update_case_description",
"category": Array [
"database",
],
"type": Array [
"change",
],
},
"kibana": Object {
"saved_object": Object {
"id": "2",
"type": "cases",
},
},
"message": "User updated the description for case id: 2 - user action id: 3",
},
],
Array [
Object {
"event": Object {
"action": "case_user_action_add_case_tags",
"category": Array [
"database",
],
"type": Array [
"change",
],
},
"kibana": Object {
"saved_object": Object {
"id": "2",
"type": "cases",
},
},
"message": "User added tags to case id: 2 - user action id: 4",
},
],
Array [
Object {
"event": Object {
"action": "case_user_action_delete_case_tags",
"category": Array [
"database",
],
"type": Array [
"deletion",
],
},
"kibana": Object {
"saved_object": Object {
"id": "2",
"type": "cases",
},
},
"message": "User deleted tags in case id: 2 - user action id: 5",
},
],
Array [
Object {
"event": Object {
"action": "case_user_action_update_case_settings",
"category": Array [
"database",
],
"type": Array [
"change",
],
},
"kibana": Object {
"saved_object": Object {
"id": "2",
"type": "cases",
},
},
"message": "User updated the settings for case id: 2 - user action id: 6",
},
],
Array [
Object {
"event": Object {
"action": "case_user_action_update_case_severity",
"category": Array [
"database",
],
"type": Array [
"change",
],
},
"kibana": Object {
"saved_object": Object {
"id": "2",
"type": "cases",
},
},
"message": "User updated the severity for case id: 2 - user action id: 7",
},
],
]
`);
});
it('creates the correct user actions when an assignee is added', async () => {
await service.bulkCreateUpdateCase({
...commonArgs,
@ -1120,6 +1459,41 @@ describe('CaseUserActionService', () => {
`);
});
it('logs the correct user actions when an assignee is added', async () => {
await service.bulkCreateUpdateCase({
...commonArgs,
originalCases,
updatedCases: updatedAssigneesCases,
user: commonArgs.user,
});
expect(mockAuditLogger.log).toBeCalledTimes(1);
expect(mockAuditLogger.log.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
Object {
"event": Object {
"action": "case_user_action_add_case_assignees",
"category": Array [
"database",
],
"type": Array [
"change",
],
},
"kibana": Object {
"saved_object": Object {
"id": "1",
"type": "cases",
},
},
"message": "User assigned uids: [1] to case id: 1 - user action id: 0",
},
],
]
`);
});
it('creates the correct user actions when an assignee is removed', async () => {
const casesWithAssigneeRemoved: Array<SavedObjectsUpdateResponse<CaseAttributes>> = [
{
@ -1177,6 +1551,51 @@ describe('CaseUserActionService', () => {
`);
});
it('logs 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(mockAuditLogger.log).toBeCalledTimes(1);
expect(mockAuditLogger.log.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
Object {
"event": Object {
"action": "case_user_action_delete_case_assignees",
"category": Array [
"database",
],
"type": Array [
"deletion",
],
},
"kibana": Object {
"saved_object": Object {
"id": "1",
"type": "cases",
},
},
"message": "User unassigned uids: [1] from case id: 1 - user action id: 0",
},
],
]
`);
});
it('creates the correct user actions when assignees are added and removed', async () => {
const caseAssignees: Array<SavedObjectsUpdateResponse<CaseAttributes>> = [
{
@ -1262,6 +1681,71 @@ describe('CaseUserActionService', () => {
`);
});
it('logs 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(mockAuditLogger.log).toBeCalledTimes(2);
expect(mockAuditLogger.log.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
Object {
"event": Object {
"action": "case_user_action_add_case_assignees",
"category": Array [
"database",
],
"type": Array [
"change",
],
},
"kibana": Object {
"saved_object": Object {
"id": "1",
"type": "cases",
},
},
"message": "User assigned uids: [2] to case id: 1 - user action id: 0",
},
],
Array [
Object {
"event": Object {
"action": "case_user_action_delete_case_assignees",
"category": Array [
"database",
],
"type": Array [
"deletion",
],
},
"kibana": Object {
"saved_object": Object {
"id": "1",
"type": "cases",
},
},
"message": "User unassigned uids: [1] from case id: 1 - user action id: 1",
},
],
]
`);
});
it('creates the correct user actions when tags are added and removed', async () => {
await service.bulkCreateUpdateCase({
...commonArgs,
@ -1333,6 +1817,61 @@ describe('CaseUserActionService', () => {
]
`);
});
it('logs the correct user actions when tags are added and removed', async () => {
await service.bulkCreateUpdateCase({
...commonArgs,
originalCases,
updatedCases: updatedTagsCases,
user: commonArgs.user,
});
expect(mockAuditLogger.log).toBeCalledTimes(2);
expect(mockAuditLogger.log.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
Object {
"event": Object {
"action": "case_user_action_add_case_tags",
"category": Array [
"database",
],
"type": Array [
"change",
],
},
"kibana": Object {
"saved_object": Object {
"id": "1",
"type": "cases",
},
},
"message": "User added tags to case id: 1 - user action id: 0",
},
],
Array [
Object {
"event": Object {
"action": "case_user_action_delete_case_tags",
"category": Array [
"database",
],
"type": Array [
"deletion",
],
},
"kibana": Object {
"saved_object": Object {
"id": "1",
"type": "cases",
},
},
"message": "User deleted tags in case id: 1 - user action id: 1",
},
],
]
`);
});
});
describe('bulkCreateAttachmentDeletion', () => {
@ -1395,20 +1934,58 @@ describe('CaseUserActionService', () => {
{ refresh: undefined }
);
});
});
describe('create', () => {
it('creates user actions', async () => {
await service.create<{ title: string }>({
unsecuredSavedObjectsClient,
attributes: { title: 'test' },
references: [],
it('logs delete comment user action', async () => {
await service.bulkCreateAttachmentDeletion({
...commonArgs,
attachments,
});
expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith(
'cases-user-actions',
{ title: 'test' },
{ references: [] }
);
expect(mockAuditLogger.log).toBeCalledTimes(2);
expect(mockAuditLogger.log.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
Object {
"event": Object {
"action": "case_user_action_delete_comment",
"category": Array [
"database",
],
"type": Array [
"deletion",
],
},
"kibana": Object {
"saved_object": Object {
"id": "1",
"type": "cases-comments",
},
},
"message": "User deleted comment id: 1 for case id: 123 - user action id: 0",
},
],
Array [
Object {
"event": Object {
"action": "case_user_action_delete_comment",
"category": Array [
"database",
],
"type": Array [
"deletion",
],
},
"kibana": Object {
"saved_object": Object {
"id": "2",
"type": "cases-comments",
},
},
"message": "User deleted comment id: 2 for case id: 123 - user action id: 1",
},
],
]
`);
});
});
@ -1458,7 +2035,6 @@ describe('CaseUserActionService', () => {
it('it returns an empty array if the response is not valid', async () => {
const res = await service.getUniqueConnectors({
unsecuredSavedObjectsClient,
caseId: '123',
});
@ -1472,7 +2048,6 @@ describe('CaseUserActionService', () => {
} as unknown as Promise<SavedObjectsFindResponse>);
const res = await service.getUniqueConnectors({
unsecuredSavedObjectsClient,
caseId: '123',
});
@ -1485,7 +2060,6 @@ describe('CaseUserActionService', () => {
it('it returns the unique connectors', async () => {
await service.getUniqueConnectors({
unsecuredSavedObjectsClient,
caseId: '123',
});

View file

@ -11,6 +11,7 @@ import type {
Logger,
SavedObject,
SavedObjectReference,
SavedObjectsBulkResponse,
SavedObjectsClientContract,
SavedObjectsFindResponse,
SavedObjectsUpdateResponse,
@ -18,6 +19,7 @@ import type {
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { KueryNode } from '@kbn/es-query';
import type { AuditLogger } from '@kbn/security-plugin/server';
import { isCommentRequestTypePersistableState } from '../../../common/utils/attachments';
import {
isConnectorUserAction,
@ -27,7 +29,6 @@ import {
isCommentUserAction,
} from '../../../common/utils/user_actions';
import type {
ActionOperationValues,
ActionTypeValues,
CaseAttributes,
CaseUserActionAttributes,
@ -37,6 +38,7 @@ import type {
CaseAssignees,
CommentRequest,
User,
UserAction as Action,
} from '../../../common/api';
import { Actions, ActionTypes, NONE_CONNECTOR_ID } from '../../../common/api';
import {
@ -45,7 +47,6 @@ import {
MAX_DOCS_PER_PAGE,
CASE_COMMENT_SAVED_OBJECT,
} from '../../../common/constants';
import type { ClientArgs } from '..';
import {
CASE_REF_NAME,
COMMENT_REF_NAME,
@ -56,10 +57,11 @@ import {
import { findConnectorIdReference } from '../transform';
import { buildFilter, combineFilters, arraysDifference } from '../../client/utils';
import type {
Attributes,
BuilderParameters,
BuilderReturnValue,
CommonArguments,
CreateUserAction,
UserActionEvent,
UserActionParameters,
} from './types';
import { BuilderFactory } from './builder_factory';
@ -69,31 +71,24 @@ import { injectPersistableReferencesToSO } from '../../attachment_framework/so_r
import type { IndexRefresh } from '../types';
import { isAssigneesArray, isStringArray } from './type_guards';
import type { CaseSavedObject } from '../../common/types';
interface GetCaseUserActionArgs extends ClientArgs {
caseId: string;
}
import { UserActionAuditLogger } from './audit_logger';
import { createDeleteEvent } from './builders/delete_case';
export interface UserActionItem {
attributes: CaseUserActionAttributesWithoutConnectorId;
references: SavedObjectReference[];
}
interface PostCaseUserActionArgs extends ClientArgs, IndexRefresh {
actions: BuilderReturnValue[];
interface PostCaseUserActionArgs extends IndexRefresh {
actions: UserActionEvent[];
}
interface CreateUserActionES<T> extends ClientArgs, IndexRefresh {
interface CreateUserActionES<T> extends IndexRefresh {
attributes: T;
references: SavedObjectReference[];
}
type CommonUserActionArgs = ClientArgs & CommonArguments;
interface BulkCreateCaseDeletionUserAction extends ClientArgs, IndexRefresh {
cases: Array<{ id: string; owner: string; connectorId: string }>;
user: User;
}
type CommonUserActionArgs = CommonArguments;
interface GetUserActionItemByDifference extends CommonUserActionArgs {
field: string;
@ -106,7 +101,7 @@ interface TypedUserActionDiffedItems<T> extends GetUserActionItemByDifference {
newValue: T[];
}
interface BulkCreateBulkUpdateCaseUserActions extends ClientArgs, IndexRefresh {
interface BulkCreateBulkUpdateCaseUserActions extends IndexRefresh {
originalCases: CaseSavedObject[];
updatedCases: Array<SavedObjectsUpdateResponse<CaseAttributes>>;
user: User;
@ -127,20 +122,35 @@ type CreatePayloadFunction<Item, ActionType extends ActionTypeValues> = (
export class CaseUserActionService {
private static readonly userActionFieldsAllowed: Set<string> = new Set(Object.keys(ActionTypes));
private readonly unsecuredSavedObjectsClient: SavedObjectsClientContract;
private readonly builderFactory: BuilderFactory;
private readonly persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry;
private readonly log: Logger;
private readonly auditLogger: UserActionAuditLogger;
constructor({
log,
persistableStateAttachmentTypeRegistry,
unsecuredSavedObjectsClient,
auditLogger,
}: {
log: Logger;
persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry;
unsecuredSavedObjectsClient: SavedObjectsClientContract;
auditLogger: AuditLogger;
}) {
this.log = log;
this.unsecuredSavedObjectsClient = unsecuredSavedObjectsClient;
this.persistableStateAttachmentTypeRegistry = persistableStateAttachmentTypeRegistry;
constructor(
private readonly log: Logger,
private readonly persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry
) {
this.builderFactory = new BuilderFactory({
persistableStateAttachmentTypeRegistry: this.persistableStateAttachmentTypeRegistry,
});
this.auditLogger = new UserActionAuditLogger(auditLogger);
}
private getUserActionItemByDifference(
params: GetUserActionItemByDifference
): BuilderReturnValue[] {
private getUserActionItemByDifference(params: GetUserActionItemByDifference): UserActionEvent[] {
const { field, originalValue, newValue, caseId, owner, user } = params;
if (!CaseUserActionService.userActionFieldsAllowed.has(field)) {
@ -228,7 +238,7 @@ export class CaseUserActionService {
}: {
commonArgs: CommonUserActionArgs;
actionType: ActionType;
action: ActionOperationValues;
action: Action;
createPayload: CreatePayloadFunction<Item, ActionType>;
modifiedItems?: Item[] | null;
}) {
@ -251,91 +261,60 @@ export class CaseUserActionService {
return userAction;
}
public async bulkCreateCaseDeletion({
unsecuredSavedObjectsClient,
cases,
user,
refresh,
}: BulkCreateCaseDeletionUserAction): Promise<void> {
this.log.debug(`Attempting to create a create case user action`);
const userActionsWithReferences = cases.reduce<BuilderReturnValue[]>((acc, caseInfo) => {
const userActionBuilder = this.builderFactory.getBuilder(ActionTypes.delete_case);
const deleteCaseUserAction = userActionBuilder?.build({
action: Actions.delete,
caseId: caseInfo.id,
user,
owner: caseInfo.owner,
connectorId: caseInfo.connectorId,
payload: {},
});
public async bulkAuditLogCaseDeletion(caseIds: string[]) {
this.log.debug(`Attempting to log bulk case deletion`);
if (deleteCaseUserAction == null) {
return acc;
}
return [...acc, deleteCaseUserAction];
}, []);
await this.bulkCreate({
unsecuredSavedObjectsClient,
actions: userActionsWithReferences,
refresh,
});
for (const id of caseIds) {
this.auditLogger.log(createDeleteEvent({ caseId: id, action: Actions.delete }));
}
}
public async bulkCreateUpdateCase({
unsecuredSavedObjectsClient,
originalCases,
updatedCases,
user,
refresh,
}: BulkCreateBulkUpdateCaseUserActions): Promise<void> {
const userActionsWithReferences = updatedCases.reduce<BuilderReturnValue[]>(
(acc, updatedCase) => {
const originalCase = originalCases.find(({ id }) => id === updatedCase.id);
const builtUserActions = updatedCases.reduce<UserActionEvent[]>((acc, updatedCase) => {
const originalCase = originalCases.find(({ id }) => id === updatedCase.id);
if (originalCase == null) {
return acc;
}
if (originalCase == null) {
return acc;
}
const caseId = updatedCase.id;
const owner = originalCase.attributes.owner;
const caseId = updatedCase.id;
const owner = originalCase.attributes.owner;
const userActions: BuilderReturnValue[] = [];
const updatedFields = Object.keys(updatedCase.attributes);
const userActions: UserActionEvent[] = [];
const updatedFields = Object.keys(updatedCase.attributes);
updatedFields
.filter((field) => CaseUserActionService.userActionFieldsAllowed.has(field))
.forEach((field) => {
const originalValue = get(originalCase, ['attributes', field]);
const newValue = get(updatedCase, ['attributes', field]);
userActions.push(
...this.getUserActionItemByDifference({
unsecuredSavedObjectsClient,
field,
originalValue,
newValue,
user,
owner,
caseId,
})
);
});
updatedFields
.filter((field) => CaseUserActionService.userActionFieldsAllowed.has(field))
.forEach((field) => {
const originalValue = get(originalCase, ['attributes', field]);
const newValue = get(updatedCase, ['attributes', field]);
userActions.push(
...this.getUserActionItemByDifference({
field,
originalValue,
newValue,
user,
owner,
caseId,
})
);
});
return [...acc, ...userActions];
},
[]
);
return [...acc, ...userActions];
}, []);
await this.bulkCreate({
unsecuredSavedObjectsClient,
actions: userActionsWithReferences,
await this.bulkCreateAndLog({
userActions: builtUserActions,
refresh,
});
}
private async bulkCreateAttachment({
unsecuredSavedObjectsClient,
caseId,
attachments,
user,
@ -343,43 +322,37 @@ export class CaseUserActionService {
refresh,
}: BulkCreateAttachmentUserAction): Promise<void> {
this.log.debug(`Attempting to create a bulk create case user action`);
const userActionsWithReferences = attachments.reduce<BuilderReturnValue[]>(
(acc, attachment) => {
const userActionBuilder = this.builderFactory.getBuilder(ActionTypes.comment);
const commentUserAction = userActionBuilder?.build({
action,
caseId,
user,
owner: attachment.owner,
attachmentId: attachment.id,
payload: { attachment: attachment.attachment },
});
const userActions = attachments.reduce<UserActionEvent[]>((acc, attachment) => {
const userActionBuilder = this.builderFactory.getBuilder(ActionTypes.comment);
const commentUserAction = userActionBuilder?.build({
action,
caseId,
user,
owner: attachment.owner,
attachmentId: attachment.id,
payload: { attachment: attachment.attachment },
});
if (commentUserAction == null) {
return acc;
}
if (commentUserAction == null) {
return acc;
}
return [...acc, commentUserAction];
},
[]
);
return [...acc, commentUserAction];
}, []);
await this.bulkCreate({
unsecuredSavedObjectsClient,
actions: userActionsWithReferences,
await this.bulkCreateAndLog({
userActions,
refresh,
});
}
public async bulkCreateAttachmentDeletion({
unsecuredSavedObjectsClient,
caseId,
attachments,
user,
refresh,
}: BulkCreateAttachmentUserAction): Promise<void> {
await this.bulkCreateAttachment({
unsecuredSavedObjectsClient,
caseId,
attachments,
user,
@ -389,14 +362,12 @@ export class CaseUserActionService {
}
public async bulkCreateAttachmentCreation({
unsecuredSavedObjectsClient,
caseId,
attachments,
user,
refresh,
}: BulkCreateAttachmentUserAction): Promise<void> {
await this.bulkCreateAttachment({
unsecuredSavedObjectsClient,
caseId,
attachments,
user,
@ -406,7 +377,6 @@ export class CaseUserActionService {
}
public async createUserAction<T extends keyof BuilderParameters>({
unsecuredSavedObjectsClient,
action,
type,
caseId,
@ -432,8 +402,7 @@ export class CaseUserActionService {
});
if (userAction) {
const { attributes, references } = userAction;
await this.create({ unsecuredSavedObjectsClient, attributes, references, refresh });
await this.createAndLog({ userAction, refresh });
}
} catch (error) {
this.log.error(`Error on creating user action of type: ${type}. Error: ${error}`);
@ -441,16 +410,13 @@ export class CaseUserActionService {
}
}
public async getAll({
unsecuredSavedObjectsClient,
caseId,
}: GetCaseUserActionArgs): Promise<SavedObjectsFindResponse<CaseUserActionResponse>> {
public async getAll(caseId: string): Promise<SavedObjectsFindResponse<CaseUserActionResponse>> {
try {
const id = caseId;
const type = CASE_SAVED_OBJECT;
const userActions =
await unsecuredSavedObjectsClient.find<CaseUserActionAttributesWithoutConnectorId>({
await this.unsecuredSavedObjectsClient.find<CaseUserActionAttributesWithoutConnectorId>({
type: CASE_USER_ACTION_SAVED_OBJECT,
hasReference: { type, id },
page: 1,
@ -469,30 +435,87 @@ export class CaseUserActionService {
}
}
public async create<T>({
unsecuredSavedObjectsClient,
public async getUserActionIdsForCases(caseIds: string[]) {
try {
this.log.debug(`Attempting to retrieve user actions associated with cases: [${caseIds}]`);
const finder = this.unsecuredSavedObjectsClient.createPointInTimeFinder({
type: CASE_USER_ACTION_SAVED_OBJECT,
hasReference: caseIds.map((id) => ({ id, type: CASE_SAVED_OBJECT })),
sortField: 'created_at',
sortOrder: 'asc',
/**
* We only care about the ids so to reduce the data returned we should limit the fields in the response. Core
* doesn't support retrieving no fields (id would always be returned anyway) so to limit it we'll only request
* the owner even though we don't need it.
*/
fields: ['owner'],
perPage: MAX_DOCS_PER_PAGE,
});
const ids: string[] = [];
for await (const userActionSavedObject of finder.find()) {
ids.push(...userActionSavedObject.saved_objects.map((userAction) => userAction.id));
}
return ids;
} catch (error) {
this.log.error(`Error retrieving user action ids for cases: [${caseIds}]: ${error}`);
throw error;
}
}
private async createAndLog({
userAction,
refresh,
}: {
userAction: UserActionEvent;
} & IndexRefresh): Promise<void> {
const createdUserAction = await this.create({ ...userAction.parameters, refresh });
this.auditLogger.log(userAction.eventDetails, createdUserAction.id);
}
private async create<T>({
attributes,
references,
refresh,
}: CreateUserActionES<T>): Promise<void> {
}: CreateUserActionES<T>): Promise<SavedObject<T>> {
try {
this.log.debug(`Attempting to POST a new case user action`);
await unsecuredSavedObjectsClient.create<T>(CASE_USER_ACTION_SAVED_OBJECT, attributes, {
references: references ?? [],
refresh,
});
return await this.unsecuredSavedObjectsClient.create<T>(
CASE_USER_ACTION_SAVED_OBJECT,
attributes,
{
references: references ?? [],
refresh,
}
);
} catch (error) {
this.log.error(`Error on POST a new case user action: ${error}`);
throw error;
}
}
public async bulkCreate({
unsecuredSavedObjectsClient,
private async bulkCreateAndLog({
userActions,
refresh,
}: { userActions: UserActionEvent[] } & IndexRefresh) {
const createdUserActions = await this.bulkCreate({ actions: userActions, refresh });
if (!createdUserActions) {
return;
}
for (let i = 0; i < userActions.length; i++) {
this.auditLogger.log(userActions[i].eventDetails, createdUserActions.saved_objects[i].id);
}
}
private async bulkCreate({
actions,
refresh,
}: PostCaseUserActionArgs): Promise<void> {
}: PostCaseUserActionArgs): Promise<SavedObjectsBulkResponse<Attributes> | undefined> {
if (isEmpty(actions)) {
return;
}
@ -500,8 +523,11 @@ export class CaseUserActionService {
try {
this.log.debug(`Attempting to POST a new case user action`);
await unsecuredSavedObjectsClient.bulkCreate(
actions.map((action) => ({ type: CASE_USER_ACTION_SAVED_OBJECT, ...action })),
return await this.unsecuredSavedObjectsClient.bulkCreate(
actions.map((action) => ({
type: CASE_USER_ACTION_SAVED_OBJECT,
...action.parameters,
})),
{ refresh }
);
} catch (error) {
@ -511,11 +537,9 @@ export class CaseUserActionService {
}
public async findStatusChanges({
unsecuredSavedObjectsClient,
caseId,
filter,
}: {
unsecuredSavedObjectsClient: SavedObjectsClientContract;
caseId: string;
filter?: KueryNode;
}): Promise<Array<SavedObject<CaseUserActionResponse>>> {
@ -539,7 +563,7 @@ export class CaseUserActionService {
const combinedFilters = combineFilters([updateActionFilter, statusChangeFilter, filter]);
const finder =
unsecuredSavedObjectsClient.createPointInTimeFinder<CaseUserActionAttributesWithoutConnectorId>(
this.unsecuredSavedObjectsClient.createPointInTimeFinder<CaseUserActionAttributesWithoutConnectorId>(
{
type: CASE_USER_ACTION_SAVED_OBJECT,
hasReference: { type: CASE_SAVED_OBJECT, id: caseId },
@ -569,10 +593,8 @@ export class CaseUserActionService {
public async getUniqueConnectors({
caseId,
filter,
unsecuredSavedObjectsClient,
}: {
caseId: string;
unsecuredSavedObjectsClient: SavedObjectsClientContract;
filter?: KueryNode;
}): Promise<Array<{ id: string }>> {
try {
@ -586,7 +608,7 @@ export class CaseUserActionService {
const combinedFilter = combineFilters([connectorsFilter, filter]);
const response = await unsecuredSavedObjectsClient.find<
const response = await this.unsecuredSavedObjectsClient.find<
CaseUserActionAttributesWithoutConnectorId,
{ references: { connectors: { ids: { buckets: Array<{ key: string }> } } } }
>({

View file

@ -98,11 +98,24 @@ export interface Attributes {
payload: Record<string, unknown>;
}
export interface BuilderReturnValue {
export interface SavedObjectParameters {
attributes: Attributes;
references: SavedObjectReference[];
}
export interface EventDetails {
getMessage: (storedUserActionId?: string) => string;
action: UserAction;
descriptiveAction: string;
savedObjectId: string;
savedObjectType: string;
}
export interface UserActionEvent {
parameters: SavedObjectParameters;
eventDetails: EventDetails;
}
export type CommonBuilderArguments = CommonArguments & {
action: UserAction;
type: UserActionTypes;

View file

@ -8,7 +8,7 @@
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
import { defaultUser, getPostCaseRequest, postCommentUserReq } from '../../../../common/lib/mock';
import { getPostCaseRequest, postCommentUserReq } from '../../../../common/lib/mock';
import {
deleteCasesByESQuery,
deleteCasesUserActions,
@ -17,7 +17,6 @@ import {
deleteCases,
createComment,
getComment,
removeServerGeneratedPropertiesFromUserAction,
getCase,
superUserSpace1Auth,
getCaseUserActions,
@ -53,7 +52,22 @@ export default ({ getService }: FtrProviderContext): void => {
expect(body).to.eql({});
});
it(`should delete a case's comments when that case gets deleted`, async () => {
it('should delete multiple cases and their user actions', async () => {
const [case1, case2] = await Promise.all([
createCase(supertest, getPostCaseRequest()),
createCase(supertest, getPostCaseRequest()),
]);
await deleteCases({ supertest, caseIDs: [case1.id, case2.id] });
const userActionsCase1 = await getCaseUserActions({ supertest, caseID: case1.id });
expect(userActionsCase1.length).to.be(0);
const userActionsCase2 = await getCaseUserActions({ supertest, caseID: case2.id });
expect(userActionsCase2.length).to.be(0);
});
it(`should delete a case's comments and user actions when that case gets deleted`, async () => {
const postedCase = await createCase(supertest, getPostCaseRequest());
const patchedCase = await createComment({
supertest,
@ -76,23 +90,16 @@ export default ({ getService }: FtrProviderContext): void => {
commentId: patchedCase.comments![0].id,
expectedHttpCode: 404,
});
const userActions = await getCaseUserActions({ supertest, caseID: postedCase.id });
expect(userActions.length).to.be(0);
});
it('should create a user action when deleting a case', async () => {
it('should delete all user actions when deleting a case', async () => {
const postedCase = await createCase(supertest, getPostCaseRequest());
await deleteCases({ supertest, caseIDs: [postedCase.id] });
const userActions = await getCaseUserActions({ supertest, caseID: postedCase.id });
const creationUserAction = removeServerGeneratedPropertiesFromUserAction(userActions[1]);
expect(creationUserAction).to.eql({
action: 'delete',
type: 'delete_case',
created_by: defaultUser,
case_id: postedCase.id,
comment_id: null,
payload: {},
owner: 'securitySolutionFixture',
});
expect(userActions.length).to.be(0);
});
it('unhappy path - 404s when case is not there', async () => {

View file

@ -68,19 +68,12 @@ export default ({ getService }: FtrProviderContext): void => {
expect(createCaseUserAction.payload.connector).to.eql(postCaseReq.connector);
});
it('creates a delete case user action when a case is deleted', async () => {
it('deletes all user actions when a case is deleted', async () => {
const theCase = await createCase(supertest, postCaseReq);
await deleteCases({ supertest, caseIDs: [theCase.id] });
const userActions = await getCaseUserActions({ supertest, caseID: theCase.id });
const userAction = userActions[1];
// One for creation and one for deletion
expect(userActions.length).to.eql(2);
expect(userAction.action).to.eql('delete');
expect(userAction.type).to.eql('delete_case');
expect(userAction.payload).to.eql({});
expect(userActions.length).to.be(0);
});
it('creates a status update user action when changing the status', async () => {