[Cases] Add bulk get attachments API (#149269)

This PR adds a new bulk get attachments API.

```
POST internal/cases/<case_id>/attachments/_bulk_get
{
    "ids": ["02441860-9b66-11ed-a8df-f1edb375c327", "2"]
}
```

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


Request
```
POST http://localhost:5601/internal/cases/attachments/_bulk_get
{
    "ids": ["283a4600-9cfd-11ed-9e3d-c96d764b0e39", "2", "382e97f0-9cfd-11ed-9e3d-c96d764b0e39"]
}
```

Response
```
{
    "attachments": [
        {
            "id": "283a4600-9cfd-11ed-9e3d-c96d764b0e39",
            "version": "WzI2MiwxXQ==",
            "comment": "Stack comment",
            "type": "user",
            "owner": "cases",
            "created_at": "2023-01-25T22:11:03.398Z",
            "created_by": {
                "email": null,
                "full_name": null,
                "username": "elastic",
                "profile_uid": "u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0"
            },
            "pushed_at": null,
            "pushed_by": null,
            "updated_at": null,
            "updated_by": null
        }
    ],
    "errors": [
        {
            "error": "Not Found",
            "message": "Saved object [cases-comments/2] not found",
            "status": 404,
            "attachmentId": "2"
        },
        {
            "error": "Bad Request",
            "message": "Attachment is not attached to case id=248d6aa0-9cfd-11ed-9e3d-c96d764b0e39",
            "status": 400,
            "attachmentId": "382e97f0-9cfd-11ed-9e3d-c96d764b0e39"
        }
    ]
}
```
</details>

<details><summary>Unauthorized example response</summary>

```
{
    "attachments": [],
    "errors": [
        {
            "error": "Forbidden",
            "message": "Unauthorized to access attachment with owner: \"securitySolution\"",
            "status": 403,
            "attachmentId": "382e97f0-9cfd-11ed-9e3d-c96d764b0e39"
        }
    ]
}

```

</details>

## Notable changes
- Created a new internal route for retrieving attachments
- Refactored the attachments service to take the saved object client in
the constructor instead of each method
- Refactored attachments service by moving the get style operations to
their own class
- Refactored the integration utilities file to move the attachment
operations to their own file
- The API will return a 400 if more than 10k ids are requested

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Jonathan Buttner 2023-01-31 08:55:50 -05:00 committed by GitHub
parent 662aa234d5
commit bd8e62e45c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 1631 additions and 522 deletions

View file

@ -380,6 +380,10 @@ Refer to the corresponding {es} logs for potential write errors.
| `success` | User has accessed a case comment.
| `failure` | User is not authorized to access a case comment.
.2+| `case_comment_bulk_get`
| `success` | User has accessed multiple case comments.
| `failure` | User is not authorized to access multiple case comments.
.2+| `case_comment_get_all`
| `success` | User has accessed case comments.
| `failure` | User is not authorized to access case comments.
@ -404,7 +408,6 @@ Refer to the corresponding {es} logs for potential write errors.
| `success` | User has accessed the user activity of a case.
| `failure` | User is not authorized to access the user activity of a case.
.2+| `case_user_actions_find`
| `success` | User has accessed the user activity of a case as part of a search operation.
| `failure` | User is not authorized to access the user activity of a case.

View file

@ -279,6 +279,22 @@ export const FindQueryParamsRt = rt.partial({
export const BulkCreateCommentRequestRt = rt.array(CommentRequestRt);
export const BulkGetAttachmentsRequestRt = rt.type({
ids: rt.array(rt.string),
});
export const BulkGetAttachmentsResponseRt = rt.type({
attachments: AllCommentsResponseRt,
errors: rt.array(
rt.type({
error: rt.string,
message: rt.string,
status: rt.union([rt.undefined, rt.number]),
attachmentId: rt.string,
})
),
});
export type FindQueryParams = rt.TypeOf<typeof FindQueryParamsRt>;
export type AttributesTypeActions = rt.TypeOf<typeof AttributesTypeActionsRt>;
export type AttributesTypeAlerts = rt.TypeOf<typeof AttributesTypeAlertsRt>;
@ -317,3 +333,5 @@ export type CommentRequestExternalReferenceType = rt.TypeOf<typeof ExternalRefer
export type CommentRequestExternalReferenceSOType = rt.TypeOf<typeof ExternalReferenceSORt>;
export type CommentRequestExternalReferenceNoSOType = rt.TypeOf<typeof ExternalReferenceNoSORt>;
export type CommentRequestPersistableStateType = rt.TypeOf<typeof PersistableStateAttachmentRt>;
export type BulkGetAttachmentsResponse = rt.TypeOf<typeof BulkGetAttachmentsResponseRt>;
export type BulkGetAttachmentsRequest = rt.TypeOf<typeof BulkGetAttachmentsRequestRt>;

View file

@ -16,6 +16,7 @@ import {
CASE_ALERTS_URL,
CASE_COMMENT_DELETE_URL,
CASE_FIND_USER_ACTIONS_URL,
INTERNAL_BULK_GET_ATTACHMENTS_URL,
} from '../constants';
export const getCaseDetailsUrl = (id: string): string => {
@ -57,3 +58,7 @@ export const getCaseConfigurationDetailsUrl = (configureID: string): string => {
export const getCasesFromAlertsUrl = (alertId: string): string => {
return CASE_ALERTS_URL.replace('{alert_id}', alertId);
};
export const getCaseBulkGetAttachmentsUrl = (id: string): string => {
return INTERNAL_BULK_GET_ATTACHMENTS_URL.replace('{case_id}', id);
};

View file

@ -88,6 +88,8 @@ export const CASE_METRICS_DETAILS_URL = `${CASES_URL}/metrics/{case_id}` as cons
export const CASES_INTERNAL_URL = '/internal/cases' as const;
export const INTERNAL_BULK_CREATE_ATTACHMENTS_URL =
`${CASES_INTERNAL_URL}/{case_id}/attachments/_bulk_create` as const;
export const INTERNAL_BULK_GET_ATTACHMENTS_URL =
`${CASES_INTERNAL_URL}/{case_id}/attachments/_bulk_get` as const;
export const INTERNAL_SUGGEST_USER_PROFILES_URL =
`${CASES_INTERNAL_URL}/_suggest_user_profiles` as const;
export const INTERNAL_CONNECTORS_URL = `${CASES_INTERNAL_URL}/{case_id}/_connectors` as const;
@ -138,6 +140,7 @@ export const OWNER_INFO = {
* Searching
*/
export const MAX_DOCS_PER_PAGE = 10000 as const;
export const MAX_BULK_GET_ATTACHMENTS = MAX_DOCS_PER_PAGE;
export const MAX_CONCURRENT_SEARCHES = 10 as const;
export const MAX_BULK_GET_CASES = 1000 as const;

View file

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

View file

@ -91,6 +91,7 @@ describe('authorization', () => {
describe('ensureAuthorized', () => {
const feature = { id: '1', cases: ['a'] };
const checkRequestReturningHasAllAsTrue = jest.fn(async () => ({ hasAllRequested: true }));
let securityStart: ReturnType<typeof securityMock.createStart>;
let featuresStart: jest.Mocked<FeaturesPluginStart>;
@ -101,7 +102,7 @@ describe('authorization', () => {
securityStart = securityMock.createStart();
securityStart.authz.mode.useRbacForRequest.mockReturnValue(true);
securityStart.authz.checkPrivilegesDynamicallyWithRequest.mockReturnValue(
jest.fn(async () => ({ hasAllRequested: true }))
checkRequestReturningHasAllAsTrue
);
featuresStart = featuresPluginMock.createStart();
@ -119,6 +120,34 @@ describe('authorization', () => {
});
});
it('calls checkRequest with no repeated owners', async () => {
expect.assertions(2);
const casesGet = securityStart.authz.actions.cases.get as jest.Mock;
casesGet.mockImplementation((owner, op) => `${owner}/${op}`);
try {
await auth.ensureAuthorized({
entities: [
{ id: '1', owner: 'b' },
{ id: '2', owner: 'b' },
],
operation: Operations.createCase,
});
} catch (error) {
expect(checkRequestReturningHasAllAsTrue).toBeCalledTimes(1);
expect(checkRequestReturningHasAllAsTrue.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"kibana": Array [
"b/createCase",
],
},
]
`);
}
});
it('throws an error when the owner passed in is not included in the features when security is disabled', async () => {
expect.assertions(1);
securityStart.authz.mode.useRbacForRequest.mockReturnValue(false);
@ -133,6 +162,23 @@ describe('authorization', () => {
}
});
it('throws an error with a single owner when the repeated owners passed in are not included in the features when security is disabled', async () => {
expect.assertions(1);
securityStart.authz.mode.useRbacForRequest.mockReturnValue(false);
try {
await auth.ensureAuthorized({
entities: [
{ id: '1', owner: 'b' },
{ id: '2', owner: 'b' },
],
operation: Operations.createCase,
});
} catch (error) {
expect(error.message).toBe('Unauthorized to create case with owners: "b"');
}
});
it('throws an error when the owner passed in is not included in the features when security undefined', async () => {
expect.assertions(1);
@ -154,6 +200,30 @@ describe('authorization', () => {
}
});
it('throws an error with a single owner when the repeated owners passed in are not included in the features when security undefined', async () => {
expect.assertions(1);
auth = await Authorization.create({
request,
spaces: spacesStart,
features: featuresStart,
auditLogger: new AuthorizationAuditLogger(mockLogger),
logger: loggingSystemMock.createLogger(),
});
try {
await auth.ensureAuthorized({
entities: [
{ id: '1', owner: 'b' },
{ id: '1', owner: 'b' },
],
operation: Operations.createCase,
});
} catch (error) {
expect(error.message).toBe('Unauthorized to create case with owners: "b"');
}
});
it('throws an error when the owner passed in is not included in the features when security is enabled', async () => {
expect.assertions(1);
@ -167,6 +237,22 @@ describe('authorization', () => {
}
});
it('throws an error with a single owner when the repeated owners passed in are not included in the features when security is enabled', async () => {
expect.assertions(1);
try {
await auth.ensureAuthorized({
entities: [
{ id: '1', owner: 'b' },
{ id: '2', owner: 'b' },
],
operation: Operations.createCase,
});
} catch (error) {
expect(error.message).toBe('Unauthorized to create case with owners: "b"');
}
});
it('logs the error thrown when the passed in owner is not one of the features', async () => {
expect.assertions(2);
@ -254,6 +340,26 @@ describe('authorization', () => {
}
});
it('throws an error with a single owner listed when the user does not have all the requested privileges', async () => {
expect.assertions(1);
securityStart.authz.checkPrivilegesDynamicallyWithRequest.mockReturnValue(
jest.fn(async () => ({ hasAllRequested: false }))
);
try {
await auth.ensureAuthorized({
entities: [
{ id: '1', owner: 'a' },
{ id: '2', owner: 'a' },
],
operation: Operations.createCase,
});
} catch (error) {
expect(error.message).toBe('Unauthorized to create case with owners: "a"');
}
});
it('throws an error when owner does not exist because it was from a disabled plugin', async () => {
expect.assertions(1);
@ -1038,6 +1144,7 @@ describe('authorization', () => {
{ id: '1', attributes: { owner: 'b' }, type: 'test', references: [] },
{ id: '2', attributes: { owner: 'c' }, type: 'test', references: [] },
],
operation: Operations.bulkGetCases,
});
} catch (error) {
expect(error.message).toBe('Unauthorized to access cases of any owner');
@ -1115,6 +1222,7 @@ describe('authorization', () => {
{ id: '1', attributes: { owner: 'a' }, type: 'test', references: [] },
{ id: '2', attributes: { owner: 'b' }, type: 'test', references: [] },
],
operation: Operations.bulkGetCases,
});
await expect(helpersPromise).resolves.not.toThrow();
@ -1183,6 +1291,7 @@ describe('authorization', () => {
{ id: '2', attributes: { owner: 'b' }, type: 'test', references: [] },
{ id: '3', attributes: { owner: 'c' }, type: 'test', references: [] },
],
operation: Operations.bulkGetCases,
});
expect(res).toEqual({
@ -1305,6 +1414,7 @@ describe('authorization', () => {
{ id: '2', attributes: { owner: 'b' }, type: 'test', references: [] },
{ id: '3', attributes: { owner: 'c' }, type: 'test', references: [] },
],
operation: Operations.bulkGetCases,
});
expect(res).toEqual({

View file

@ -14,7 +14,7 @@ import type { Space, SpacesPluginStart } from '@kbn/spaces-plugin/server';
import type { AuthFilterHelpers, OwnerEntity } from './types';
import { getOwnersFilter, groupByAuthorization } from './utils';
import type { OperationDetails } from '.';
import { AuthorizationAuditLogger, Operations } from '.';
import { AuthorizationAuditLogger } from '.';
import { createCaseError } from '../common/error';
/**
@ -109,10 +109,9 @@ export class Authorization {
operation: OperationDetails;
}) {
try {
await this._ensureAuthorized(
entities.map((entity) => entity.owner),
operation
);
const uniqueOwners = Array.from(new Set(entities.map((entity) => entity.owner)));
await this._ensureAuthorized(uniqueOwners, operation);
} catch (error) {
this.logSavedObjects({ entities, operation, error });
throw error;
@ -128,13 +127,15 @@ export class Authorization {
*
* @param savedObjects an array of saved objects to be authorized. Each saved objects should contain
* an ID and an owner
* @param operation the operation that should be authorized
*/
public async getAndEnsureAuthorizedEntities<T extends { owner: string }>({
savedObjects,
operation,
}: {
savedObjects: Array<SavedObject<T>>;
operation: OperationDetails;
}): Promise<{ authorized: Array<SavedObject<T>>; unauthorized: Array<SavedObject<T>> }> {
const operation = Operations.bulkGetCases;
const entities = savedObjects.map((so) => ({
id: so.id,
owner: so.attributes.owner,

View file

@ -230,6 +230,14 @@ const AttachmentOperations = {
docType: 'comments',
savedObjectType: CASE_COMMENT_SAVED_OBJECT,
},
[ReadOperations.BulkGetAttachments]: {
ecsType: EVENT_TYPES.access,
name: ACCESS_COMMENT_OPERATION,
action: 'case_comment_bulk_get',
verbs: accessVerbs,
docType: 'comments',
savedObjectType: CASE_COMMENT_SAVED_OBJECT,
},
[ReadOperations.GetAllComments]: {
ecsType: EVENT_TYPES.access,
name: ACCESS_COMMENT_OPERATION,

View file

@ -33,6 +33,7 @@ export enum ReadOperations {
GetCaseIDsByAlertID = 'getCaseIDsByAlertID',
GetCaseStatuses = 'getCaseStatuses',
GetComment = 'getComment',
BulkGetAttachments = 'bulkGetAttachments',
GetAllComments = 'getAllComments',
FindComments = 'findComments',
GetTags = 'getTags',

View file

@ -16,7 +16,7 @@ import {
isCommentRequestTypeExternalReference,
isCommentRequestTypePersistableState,
} from '../../../common/utils/attachments';
import type { CaseResponse, CommentRequest } from '../../../common/api';
import type { CaseResponse } from '../../../common/api';
import { CommentRequestRt, throwErrors } from '../../../common/api';
import { CaseCommentModel } from '../../common/models';
@ -25,20 +25,7 @@ import type { CasesClientArgs } from '..';
import { decodeCommentRequest } from '../utils';
import { Operations } from '../../authorization';
/**
* The arguments needed for creating a new attachment to a case.
*/
export interface AddArgs {
/**
* The case ID that this attachment will be associated with
*/
caseId: string;
/**
* The attachment values.
*/
comment: CommentRequest;
}
import type { AddArgs } from './types';
/**
* Create an attachment to a case.

View file

@ -12,7 +12,7 @@ import { identity } from 'fp-ts/lib/function';
import { SavedObjectsUtils } from '@kbn/core/server';
import type { BulkCreateCommentRequest, CaseResponse, CommentRequest } from '../../../common/api';
import type { CaseResponse, CommentRequest } from '../../../common/api';
import { BulkCreateCommentRequestRt, throwErrors } from '../../../common/api';
import { CaseCommentModel } from '../../common/models';
@ -22,11 +22,7 @@ import type { CasesClientArgs } from '..';
import { decodeCommentRequest } from '../utils';
import type { OwnerEntity } from '../../authorization';
import { Operations } from '../../authorization';
export interface BulkCreateArgs {
caseId: string;
attachments: BulkCreateCommentRequest;
}
import type { BulkCreateArgs } from './types';
/**
* Create an attachment to a case.

View file

@ -0,0 +1,181 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { SavedObject, SavedObjectReference } from '@kbn/core/server';
import Boom from '@hapi/boom';
import { pipe } from 'fp-ts/lib/pipeable';
import { fold } from 'fp-ts/lib/Either';
import { identity } from 'fp-ts/lib/function';
import { partition } from 'lodash';
import { CASE_SAVED_OBJECT, MAX_BULK_GET_ATTACHMENTS } from '../../../common/constants';
import type { BulkGetAttachmentsResponse, CommentAttributes } from '../../../common/api';
import {
excess,
throwErrors,
BulkGetAttachmentsResponseRt,
BulkGetAttachmentsRequestRt,
} from '../../../common/api';
import { flattenCommentSavedObjects } from '../../common/utils';
import { createCaseError } from '../../common/error';
import type { CasesClientArgs, SOWithErrors } from '../types';
import { Operations } from '../../authorization';
import type { BulkGetArgs } from './types';
import type { BulkOptionalAttributes, OptionalAttributes } from '../../services/attachments/types';
import { CASE_REF_NAME } from '../../common/constants';
import type { CasesClient } from '../client';
type AttachmentSavedObjectWithErrors = SOWithErrors<CommentAttributes>;
type AttachmentSavedObject = SavedObject<CommentAttributes>;
/**
* Retrieves multiple attachments by id.
*/
export async function bulkGet(
{ attachmentIDs, caseID }: BulkGetArgs,
clientArgs: CasesClientArgs,
casesClient: CasesClient
): Promise<BulkGetAttachmentsResponse> {
const {
services: { attachmentService },
logger,
authorization,
} = clientArgs;
try {
const request = pipe(
excess(BulkGetAttachmentsRequestRt).decode({ ids: attachmentIDs }),
fold(throwErrors(Boom.badRequest), identity)
);
throwErrorIfIdsExceedTheLimit(request.ids);
// perform an authorization check for the case
await casesClient.cases.resolve({ id: caseID });
const attachments = await attachmentService.getter.bulkGet(request.ids);
const { validAttachments, attachmentsWithErrors, invalidAssociationAttachments } =
partitionAttachments(caseID, attachments);
const { authorized: authorizedAttachments, unauthorized: unauthorizedAttachments } =
await authorization.getAndEnsureAuthorizedEntities({
savedObjects: validAttachments,
operation: Operations.bulkGetAttachments,
});
const errors = constructErrors({
associationErrors: invalidAssociationAttachments,
unauthorizedAttachments,
soBulkGetErrors: attachmentsWithErrors,
caseId: caseID,
});
return BulkGetAttachmentsResponseRt.encode({
attachments: flattenCommentSavedObjects(authorizedAttachments),
errors,
});
} catch (error) {
throw createCaseError({
message: `Failed to bulk get attachments for case id: ${caseID}: ${error}`,
error,
logger,
});
}
}
const throwErrorIfIdsExceedTheLimit = (ids: string[]) => {
if (ids.length > MAX_BULK_GET_ATTACHMENTS) {
throw Boom.badRequest(
`Maximum request limit of ${MAX_BULK_GET_ATTACHMENTS} attachments reached`
);
}
};
interface PartitionedAttachments {
validAttachments: AttachmentSavedObject[];
attachmentsWithErrors: AttachmentSavedObjectWithErrors;
invalidAssociationAttachments: AttachmentSavedObject[];
}
const partitionAttachments = (
caseId: string,
attachments: BulkOptionalAttributes<CommentAttributes>
): PartitionedAttachments => {
const [attachmentsWithoutErrors, errors] = partitionBySOError(attachments.saved_objects);
const [caseAttachments, invalidAssociationAttachments] = partitionByCaseAssociation(
caseId,
attachmentsWithoutErrors
);
return {
validAttachments: caseAttachments,
attachmentsWithErrors: errors,
invalidAssociationAttachments,
};
};
const partitionBySOError = (attachments: Array<OptionalAttributes<CommentAttributes>>) =>
partition(
attachments,
(attachment) => attachment.error == null && attachment.attributes != null
) as [AttachmentSavedObject[], AttachmentSavedObjectWithErrors];
const partitionByCaseAssociation = (caseId: string, attachments: AttachmentSavedObject[]) =>
partition(attachments, (attachment) => {
const ref = getCaseReference(attachment.references);
return caseId === ref?.id;
});
const getCaseReference = (references: SavedObjectReference[]): SavedObjectReference | undefined => {
return references.find((ref) => ref.name === CASE_REF_NAME && ref.type === CASE_SAVED_OBJECT);
};
const constructErrors = ({
caseId,
soBulkGetErrors,
associationErrors,
unauthorizedAttachments,
}: {
caseId: string;
soBulkGetErrors: AttachmentSavedObjectWithErrors;
associationErrors: AttachmentSavedObject[];
unauthorizedAttachments: AttachmentSavedObject[];
}): BulkGetAttachmentsResponse['errors'] => {
const errors: BulkGetAttachmentsResponse['errors'] = [];
for (const soError of soBulkGetErrors) {
errors.push({
error: soError.error.error,
message: soError.error.message,
status: soError.error.statusCode,
attachmentId: soError.id,
});
}
for (const attachment of associationErrors) {
errors.push({
error: 'Bad Request',
message: `Attachment is not attached to case id=${caseId}`,
status: 400,
attachmentId: attachment.id,
});
}
for (const unauthorizedAttachment of unauthorizedAttachments) {
errors.push({
error: 'Forbidden',
message: `Unauthorized to access attachment with owner: "${unauthorizedAttachment.attributes.owner}"`,
status: 403,
attachmentId: unauthorizedAttachment.id,
});
}
return errors;
};

View file

@ -5,21 +5,35 @@
* 2.0.
*/
import type { AlertResponse, CommentResponse } from '../../../common/api';
import type {
AlertResponse,
AllCommentsResponse,
BulkGetAttachmentsResponse,
CaseResponse,
CommentResponse,
CommentsResponse,
} from '../../../common/api';
import type { CasesClient } from '../client';
import type { CasesClientInternal } from '../client_internal';
import type { IAllCommentsResponse, ICaseResponse, ICommentsResponse } from '../typedoc_interfaces';
import type { CasesClientArgs } from '../types';
import type { AddArgs } from './add';
import { addComment } from './add';
import type { BulkCreateArgs } from './bulk_create';
import type {
BulkCreateArgs,
AddArgs,
DeleteAllArgs,
DeleteArgs,
FindArgs,
GetAllAlertsAttachToCase,
GetAllArgs,
GetArgs,
UpdateArgs,
BulkGetArgs,
} from './types';
import { bulkCreate } from './bulk_create';
import type { DeleteAllArgs, DeleteArgs } from './delete';
import { deleteAll, deleteComment } from './delete';
import type { FindArgs, GetAllAlertsAttachToCase, GetAllArgs, GetArgs } from './get';
import { find, get, getAll, getAllAlertsAttachToCase } from './get';
import type { UpdateArgs } from './update';
import { bulkGet } from './bulk_get';
import { update } from './update';
/**
@ -29,8 +43,9 @@ export interface AttachmentsSubClient {
/**
* Adds an attachment to a case.
*/
add(params: AddArgs): Promise<ICaseResponse>;
bulkCreate(params: BulkCreateArgs): Promise<ICaseResponse>;
add(params: AddArgs): Promise<CaseResponse>;
bulkCreate(params: BulkCreateArgs): Promise<CaseResponse>;
bulkGet(params: BulkGetArgs): Promise<BulkGetAttachmentsResponse>;
/**
* Deletes all attachments associated with a single case.
*/
@ -42,7 +57,7 @@ export interface AttachmentsSubClient {
/**
* Retrieves all comments matching the search criteria.
*/
find(findArgs: FindArgs): Promise<ICommentsResponse>;
find(findArgs: FindArgs): Promise<CommentsResponse>;
/**
* Retrieves all alerts attach to a case given a single case ID
*/
@ -50,7 +65,7 @@ export interface AttachmentsSubClient {
/**
* Gets all attachments for a single case.
*/
getAll(getAllArgs: GetAllArgs): Promise<IAllCommentsResponse>;
getAll(getAllArgs: GetAllArgs): Promise<AllCommentsResponse>;
/**
* Retrieves a single attachment for a case.
*/
@ -60,7 +75,7 @@ export interface AttachmentsSubClient {
*
* The request must include all fields for the attachment. Even the fields that are not changing.
*/
update(updateArgs: UpdateArgs): Promise<ICaseResponse>;
update(updateArgs: UpdateArgs): Promise<CaseResponse>;
}
/**
@ -76,6 +91,7 @@ export const createAttachmentsSubClient = (
const attachmentSubClient: AttachmentsSubClient = {
add: (params: AddArgs) => addComment(params, clientArgs),
bulkCreate: (params: BulkCreateArgs) => bulkCreate(params, clientArgs),
bulkGet: (params) => bulkGet(params, clientArgs, casesClient),
deleteAll: (deleteAllArgs: DeleteAllArgs) => deleteAll(deleteAllArgs, clientArgs),
delete: (deleteArgs: DeleteArgs) => deleteComment(deleteArgs, clientArgs),
find: (findArgs: FindArgs) => find(findArgs, clientArgs),

View file

@ -15,30 +15,7 @@ import { CASE_SAVED_OBJECT, MAX_CONCURRENT_SEARCHES } from '../../../common/cons
import type { CasesClientArgs } from '../types';
import { createCaseError } from '../../common/error';
import { Operations } from '../../authorization';
/**
* Parameters for deleting all comments of a case.
*/
export interface DeleteAllArgs {
/**
* The case ID to delete all attachments for
*/
caseID: string;
}
/**
* Parameters for deleting a single attachment of a case.
*/
export interface DeleteArgs {
/**
* The case ID to delete an attachment from
*/
caseID: string;
/**
* The attachment ID to delete
*/
attachmentID: string;
}
import type { DeleteAllArgs, DeleteArgs } from './types';
/**
* Delete all comments for a case.
@ -51,7 +28,6 @@ export async function deleteAll(
): Promise<void> {
const {
user,
unsecuredSavedObjectsClient,
services: { caseService, attachmentService, userActionService },
logger,
authorization,
@ -76,7 +52,6 @@ export async function deleteAll(
const mapper = async (comment: SavedObject<CommentAttributes>) =>
attachmentService.delete({
unsecuredSavedObjectsClient,
attachmentId: comment.id,
refresh: false,
});
@ -115,15 +90,13 @@ export async function deleteComment(
) {
const {
user,
unsecuredSavedObjectsClient,
services: { attachmentService, userActionService },
logger,
authorization,
} = clientArgs;
try {
const myComment = await attachmentService.get({
unsecuredSavedObjectsClient,
const myComment = await attachmentService.getter.get({
attachmentId: attachmentID,
});
@ -145,7 +118,6 @@ export async function deleteComment(
}
await attachmentService.delete({
unsecuredSavedObjectsClient,
attachmentId: attachmentID,
refresh: false,
});

View file

@ -12,7 +12,6 @@ import type {
AttributesTypeAlerts,
CommentResponse,
CommentsResponse,
FindQueryParams,
} from '../../../common/api';
import { AllCommentsResponseRt, CommentResponseRt, CommentsResponseRt } from '../../../common/api';
import {
@ -29,48 +28,7 @@ import { combineFilters, stringToKueryNode } from '../utils';
import { Operations } from '../../authorization';
import { includeFieldsRequiredForAuthentication } from '../../authorization/utils';
import type { CasesClient } from '../client';
/**
* Parameters for finding attachments of a case
*/
export interface FindArgs {
/**
* The case ID for finding associated attachments
*/
caseID: string;
/**
* Optional parameters for filtering the returned attachments
*/
queryParams?: FindQueryParams;
}
/**
* Parameters for retrieving all attachments of a case
*/
export interface GetAllArgs {
/**
* The case ID to retrieve all attachments for
*/
caseID: string;
}
export interface GetArgs {
/**
* The ID of the case to retrieve an attachment from
*/
caseID: string;
/**
* The ID of the attachment to retrieve
*/
attachmentID: string;
}
export interface GetAllAlertsAttachToCase {
/**
* The ID of the case to retrieve the alerts from
*/
caseId: string;
}
import type { FindArgs, GetAllAlertsAttachToCase, GetAllArgs, GetArgs } from './types';
const normalizeAlertResponse = (alerts: Array<SavedObject<AttributesTypeAlerts>>): AlertResponse =>
alerts.reduce((acc: AlertResponse, alert) => {
@ -92,8 +50,6 @@ const normalizeAlertResponse = (alerts: Array<SavedObject<AttributesTypeAlerts>>
/**
* Retrieves all alerts attached to a specific case.
*
* @ignore
*/
export const getAllAlertsAttachToCase = async (
{ caseId }: GetAllAlertsAttachToCase,
@ -101,7 +57,6 @@ export const getAllAlertsAttachToCase = async (
casesClient: CasesClient
): Promise<AlertResponse> => {
const {
unsecuredSavedObjectsClient,
authorization,
services: { attachmentService },
logger,
@ -117,8 +72,7 @@ export const getAllAlertsAttachToCase = async (
const { filter: authorizationFilter, ensureSavedObjectsAreAuthorized } =
await authorization.getAuthorizationFilter(Operations.getAlertsAttachedToCase);
const alerts = await attachmentService.getAllAlertsAttachToCase({
unsecuredSavedObjectsClient,
const alerts = await attachmentService.getter.getAllAlertsAttachToCase({
caseId: theCase.id,
filter: authorizationFilter,
});
@ -142,8 +96,6 @@ export const getAllAlertsAttachToCase = async (
/**
* Retrieves the attachments for a case entity. This support pagination.
*
* @ignore
*/
export async function find(
{ caseID, queryParams }: FindArgs,
@ -219,8 +171,6 @@ export async function find(
/**
* Retrieves a single attachment by its ID.
*
* @ignore
*/
export async function get(
{ attachmentID, caseID }: GetArgs,
@ -228,14 +178,12 @@ export async function get(
): Promise<CommentResponse> {
const {
services: { attachmentService },
unsecuredSavedObjectsClient,
logger,
authorization,
} = clientArgs;
try {
const comment = await attachmentService.get({
unsecuredSavedObjectsClient,
const comment = await attachmentService.getter.get({
attachmentId: attachmentID,
});
@ -256,8 +204,6 @@ export async function get(
/**
* Retrieves all the attachments for a case.
*
* @ignore
*/
export async function getAll(
{ caseID }: GetAllArgs,

View file

@ -0,0 +1,120 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type {
BulkCreateCommentRequest,
CommentPatchRequest,
CommentRequest,
FindQueryParams,
} from '../../../common/api';
/**
* The arguments needed for creating a new attachment to a case.
*/
export interface AddArgs {
/**
* The case ID that this attachment will be associated with
*/
caseId: string;
/**
* The attachment values.
*/
comment: CommentRequest;
}
export interface BulkCreateArgs {
caseId: string;
attachments: BulkCreateCommentRequest;
}
/**
* Parameters for deleting all comments of a case.
*/
export interface DeleteAllArgs {
/**
* The case ID to delete all attachments for
*/
caseID: string;
}
/**
* Parameters for deleting a single attachment of a case.
*/
export interface DeleteArgs {
/**
* The case ID to delete an attachment from
*/
caseID: string;
/**
* The attachment ID to delete
*/
attachmentID: string;
}
/**
* Parameters for finding attachments of a case
*/
export interface FindArgs {
/**
* The case ID for finding associated attachments
*/
caseID: string;
/**
* Optional parameters for filtering the returned attachments
*/
queryParams?: FindQueryParams;
}
/**
* Parameters for retrieving all attachments of a case
*/
export interface GetAllArgs {
/**
* The case ID to retrieve all attachments for
*/
caseID: string;
}
export interface GetArgs {
/**
* The ID of the case to retrieve an attachment from
*/
caseID: string;
/**
* The ID of the attachment to retrieve
*/
attachmentID: string;
}
export interface BulkGetArgs {
caseID: string;
/**
* The ids of the attachments
*/
attachmentIDs: string[];
}
export interface GetAllAlertsAttachToCase {
/**
* The ID of the case to retrieve the alerts from
*/
caseId: string;
}
/**
* Parameters for updating a single attachment
*/
export interface UpdateArgs {
/**
* The ID of the case that is associated with this attachment
*/
caseID: string;
/**
* The full attachment request with the fields updated with appropriate values
*/
updateRequest: CommentPatchRequest;
}

View file

@ -10,25 +10,12 @@ import Boom from '@hapi/boom';
import { CaseCommentModel } from '../../common/models';
import { createCaseError } from '../../common/error';
import { isCommentRequestTypeExternalReference } from '../../../common/utils/attachments';
import type { CaseResponse, CommentPatchRequest } from '../../../common/api';
import type { CaseResponse } from '../../../common/api';
import { CASE_SAVED_OBJECT } from '../../../common/constants';
import type { CasesClientArgs } from '..';
import { decodeCommentRequest } from '../utils';
import { Operations } from '../../authorization';
/**
* Parameters for updating a single attachment
*/
export interface UpdateArgs {
/**
* The ID of the case that is associated with this attachment
*/
caseID: string;
/**
* The full attachment request with the fields updated with appropriate values
*/
updateRequest: CommentPatchRequest;
}
import type { UpdateArgs } from './types';
/**
* Update an attachment.
@ -41,7 +28,6 @@ export async function update(
): Promise<CaseResponse> {
const {
services: { attachmentService },
unsecuredSavedObjectsClient,
logger,
authorization,
} = clientArgs;
@ -55,8 +41,7 @@ export async function update(
decodeCommentRequest(queryRestAttributes);
const myComment = await attachmentService.get({
unsecuredSavedObjectsClient,
const myComment = await attachmentService.getter.get({
attachmentId: queryCommentId,
});

View file

@ -32,7 +32,7 @@ describe('bulkGet', () => {
const caseSO = mockCases[0];
const clientArgs = createCasesClientMockArgs();
clientArgs.services.caseService.getCases.mockResolvedValue({ saved_objects: [caseSO] });
clientArgs.services.attachmentService.getCaseCommentStats.mockResolvedValue(new Map());
clientArgs.services.attachmentService.getter.getCaseCommentStats.mockResolvedValue(new Map());
clientArgs.authorization.getAndEnsureAuthorizedEntities.mockResolvedValue({
authorized: [caseSO],

View file

@ -11,13 +11,13 @@ import { fold } from 'fp-ts/lib/Either';
import { identity } from 'fp-ts/lib/function';
import { pick, partition } from 'lodash';
import type { SavedObjectError } from '@kbn/core-saved-objects-common';
import { MAX_BULK_GET_CASES } from '../../../common/constants';
import type {
CasesBulkGetResponse,
CasesBulkGetResponseCertainFields,
CasesBulkGetRequestCertainFields,
CaseResponse,
CaseAttributes,
} from '../../../common/api';
import {
CasesBulkGetRequestRt,
@ -30,11 +30,12 @@ import {
import { getTypeProps } from '../../../common/api/runtime_types';
import { createCaseError } from '../../common/error';
import { asArray, flattenCaseSavedObject } from '../../common/utils';
import type { CasesClientArgs } from '../types';
import type { CasesClientArgs, SOWithErrors } from '../types';
import { includeFieldsRequiredForAuthentication } from '../../authorization/utils';
import type { CaseSavedObject } from '../../common/types';
import { Operations } from '../../authorization';
type SOWithErrors = Array<CaseSavedObject & { error: SavedObjectError }>;
type CaseSavedObjectWithErrors = SOWithErrors<CaseAttributes>;
/**
* Retrieves multiple cases by ids.
@ -47,7 +48,6 @@ export const bulkGet = async <Field extends keyof CaseResponse = keyof CaseRespo
services: { caseService, attachmentService },
logger,
authorization,
unsecuredSavedObjectsClient,
} = clientArgs;
try {
@ -66,18 +66,20 @@ export const bulkGet = async <Field extends keyof CaseResponse = keyof CaseRespo
const [validCases, soBulkGetErrors] = partition(
cases.saved_objects,
(caseInfo) => caseInfo.error === undefined
) as [CaseSavedObject[], SOWithErrors];
) as [CaseSavedObject[], CaseSavedObjectWithErrors];
const { authorized: authorizedCases, unauthorized: unauthorizedCases } =
await authorization.getAndEnsureAuthorizedEntities({ savedObjects: validCases });
await authorization.getAndEnsureAuthorizedEntities({
savedObjects: validCases,
operation: Operations.bulkGetCases,
});
const requestForTotals = ['totalComment', 'totalAlerts'].some(
(totalKey) => !fields || fields.includes(totalKey)
);
const commentTotals = requestForTotals
? await attachmentService.getCaseCommentStats({
unsecuredSavedObjectsClient,
? await attachmentService.getter.getCaseCommentStats({
caseIds: authorizedCases.map((theCase) => theCase.id),
})
: new Map();
@ -139,7 +141,7 @@ const throwErrorIfCaseIdsReachTheLimit = (ids: string[]) => {
};
const constructErrors = (
soBulkGetErrors: SOWithErrors,
soBulkGetErrors: CaseSavedObjectWithErrors,
unauthorizedCases: CaseSavedObject[]
): CasesBulkGetResponse['errors'] => {
const errors: CasesBulkGetResponse['errors'] = [];

View file

@ -24,7 +24,6 @@ import { Operations } from '../../authorization';
*/
export async function deleteCases(ids: string[], clientArgs: CasesClientArgs): Promise<void> {
const {
unsecuredSavedObjectsClient,
services: { caseService, attachmentService, userActionService },
logger,
authorization,
@ -50,9 +49,8 @@ export async function deleteCases(ids: string[], clientArgs: CasesClientArgs): P
entities: Array.from(entities.values()),
});
const attachmentIds = await attachmentService.getAttachmentIdsForCases({
const attachmentIds = await attachmentService.getter.getAttachmentIdsForCases({
caseIds: ids,
unsecuredSavedObjectsClient,
});
const userActionIds = await userActionService.getUserActionIdsForCases(ids);

View file

@ -67,7 +67,6 @@ export const getCasesByAlertID = async (
services: { caseService, attachmentService },
logger,
authorization,
unsecuredSavedObjectsClient,
} = clientArgs;
try {
@ -107,8 +106,7 @@ export const getCasesByAlertID = async (
return [];
}
const commentStats = await attachmentService.getCaseCommentStats({
unsecuredSavedObjectsClient,
const commentStats = await attachmentService.getter.getCaseCommentStats({
caseIds,
});

View file

@ -241,7 +241,6 @@ export const push = async (
}),
attachmentService.bulkUpdate({
unsecuredSavedObjectsClient,
comments: comments.saved_objects
.filter((comment) => comment.attributes.pushed_at == null)
.map((comment) => ({

View file

@ -170,10 +170,11 @@ export class CasesClientFactory {
}): CasesServices {
this.validateInitialization();
const attachmentService = new AttachmentService(
this.logger,
this.options.persistableStateAttachmentTypeRegistry
);
const attachmentService = new AttachmentService({
log: this.logger,
persistableStateAttachmentTypeRegistry: this.options.persistableStateAttachmentTypeRegistry,
unsecuredSavedObjectsClient,
});
const caseService = new CasesService({
log: this.logger,

View file

@ -25,7 +25,6 @@ export class Actions extends SingleCaseAggregationHandler {
public async compute(): Promise<SingleCaseMetricsResponse> {
const {
unsecuredSavedObjectsClient,
authorization,
services: { attachmentService },
logger,
@ -48,7 +47,6 @@ export class Actions extends SingleCaseAggregationHandler {
}, {});
const response = await attachmentService.executeCaseActionsAggregations({
unsecuredSavedObjectsClient,
caseId: theCase.id,
filter: authorizationFilter,
aggregations,

View file

@ -18,7 +18,6 @@ export class AlertsCount extends SingleCaseBaseHandler {
public async compute(): Promise<SingleCaseMetricsResponse> {
const {
unsecuredSavedObjectsClient,
authorization,
services: { attachmentService },
logger,
@ -38,7 +37,6 @@ export class AlertsCount extends SingleCaseBaseHandler {
);
const alertsCount = await attachmentService.countAlertsAttachedToCase({
unsecuredSavedObjectsClient,
caseId: theCase.id,
filter: authorizationFilter,
});

View file

@ -74,6 +74,7 @@ type AttachmentsSubClientMock = jest.Mocked<AttachmentsSubClient>;
const createAttachmentsSubClientMock = (): AttachmentsSubClientMock => {
return {
bulkGet: jest.fn(),
add: jest.fn(),
bulkCreate: jest.fn(),
deleteAll: jest.fn(),

View file

@ -6,13 +6,14 @@
*/
import type { PublicMethodsOf } from '@kbn/utility-types';
import type { SavedObjectsClientContract, Logger } from '@kbn/core/server';
import type { SavedObjectsClientContract, Logger, SavedObject } from '@kbn/core/server';
import type { ActionsClient } from '@kbn/actions-plugin/server';
import type { LensServerPluginSetup } from '@kbn/lens-plugin/server';
import type { SecurityPluginStart } from '@kbn/security-plugin/server';
import type { IBasePath } from '@kbn/core-http-browser';
import type { ISavedObjectsSerializer } from '@kbn/core-saved-objects-server';
import type { KueryNode } from '@kbn/es-query';
import type { SavedObjectError } from '@kbn/core-saved-objects-common';
import type { CasesFindRequest, User } from '../../common/api';
import type { Authorization } from '../authorization/authorization';
import type {
@ -64,3 +65,5 @@ export type CasesFindQueryParams = Partial<
'tags' | 'reporters' | 'status' | 'severity' | 'owner' | 'from' | 'to' | 'assignees'
> & { sortByField?: string; authorizationFilter?: KueryNode }
>;
export type SOWithErrors<T> = Array<SavedObject<T> & { error: SavedObjectError }>;

View file

@ -95,8 +95,7 @@ export class CaseCommentModel {
};
if (queryRestAttributes.type === CommentType.user && queryRestAttributes?.comment) {
const currentComment = (await this.params.services.attachmentService.get({
unsecuredSavedObjectsClient: this.params.unsecuredSavedObjectsClient,
const currentComment = (await this.params.services.attachmentService.getter.get({
attachmentId: id,
})) as SavedObject<CommentRequestUserType>;
@ -110,7 +109,6 @@ export class CaseCommentModel {
const [comment, commentableCase] = await Promise.all([
this.params.services.attachmentService.update({
unsecuredSavedObjectsClient: this.params.unsecuredSavedObjectsClient,
attachmentId: id,
updatedAttributes: {
...queryRestAttributes,
@ -212,7 +210,6 @@ export class CaseCommentModel {
const [comment, commentableCase] = await Promise.all([
this.params.services.attachmentService.create({
unsecuredSavedObjectsClient: this.params.unsecuredSavedObjectsClient,
attributes: transformNewComment({
createdDate,
...commentReq,
@ -276,7 +273,6 @@ export class CaseCommentModel {
private async validateAlertsLimitOnCase(totalAlertsInReq: number) {
const alertsValueCount =
await this.params.services.attachmentService.valueCountAlertsAttachedToCase({
unsecuredSavedObjectsClient: this.params.unsecuredSavedObjectsClient,
caseId: this.caseInfo.id,
});
@ -410,7 +406,6 @@ export class CaseCommentModel {
const [newlyCreatedAttachments, commentableCase] = await Promise.all([
this.params.services.attachmentService.bulkCreate({
unsecuredSavedObjectsClient: this.params.unsecuredSavedObjectsClient,
attachments: attachments.map(({ id, ...attachment }) => {
return {
attributes: transformNewComment({

View file

@ -11,6 +11,7 @@ import { bulkCreateAttachmentsRoute } from './internal/bulk_create_attachments';
import { bulkGetCasesRoute } from './internal/bulk_get_cases';
import { suggestUserProfilesRoute } from './internal/suggest_user_profiles';
import type { CaseRoute } from './types';
import { bulkGetAttachmentsRoute } from './internal/bulk_get_attachments';
export const getInternalRoutes = (userProfileService: UserProfileService) =>
[
@ -18,4 +19,5 @@ export const getInternalRoutes = (userProfileService: UserProfileService) =>
suggestUserProfilesRoute(userProfileService),
getConnectorsRoute,
bulkGetCasesRoute,
bulkGetAttachmentsRoute,
] as CaseRoute[];

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 { schema } from '@kbn/config-schema';
import type { BulkGetAttachmentsRequest } from '../../../../common/api';
import { INTERNAL_BULK_GET_ATTACHMENTS_URL } from '../../../../common/constants';
import { createCaseError } from '../../../common/error';
import { createCasesRoute } from '../create_cases_route';
import { escapeHatch } from '../utils';
export const bulkGetAttachmentsRoute = createCasesRoute({
method: 'post',
path: INTERNAL_BULK_GET_ATTACHMENTS_URL,
params: {
params: schema.object({
case_id: schema.string(),
}),
body: escapeHatch,
},
handler: async ({ context, request, response }) => {
try {
const caseContext = await context.cases;
const client = await caseContext.getCasesClient();
const body = request.body as BulkGetAttachmentsRequest;
return response.ok({
body: await client.attachments.bulkGet({
caseID: request.params.case_id,
attachmentIDs: body.ids,
}),
});
} catch (error) {
throw createCaseError({
message: `Failed to bulk get attachments in route case id: ${request.params.case_id}: ${error}`,
error,
});
}
},
});

View file

@ -28,7 +28,11 @@ describe('CasesService', () => {
beforeEach(() => {
jest.clearAllMocks();
service = new AttachmentService(mockLogger, persistableStateAttachmentTypeRegistry);
service = new AttachmentService({
log: mockLogger,
persistableStateAttachmentTypeRegistry,
unsecuredSavedObjectsClient,
});
});
describe('update', () => {
@ -44,7 +48,6 @@ describe('CasesService', () => {
unsecuredSavedObjectsClient.update.mockResolvedValue(soClientRes);
const res = await service.update({
unsecuredSavedObjectsClient,
attachmentId: '1',
updatedAttributes: persistableStateAttachment,
options: { references: [] },
@ -60,7 +63,6 @@ describe('CasesService', () => {
});
const res = await service.update({
unsecuredSavedObjectsClient,
attachmentId: '1',
updatedAttributes: externalReferenceAttachmentSO,
options: { references: [] },
@ -76,7 +78,6 @@ describe('CasesService', () => {
});
const res = await service.update({
unsecuredSavedObjectsClient,
attachmentId: '1',
updatedAttributes: externalReferenceAttachmentES,
options: { references: [] },
@ -111,7 +112,6 @@ describe('CasesService', () => {
});
const res = await service.bulkUpdate({
unsecuredSavedObjectsClient,
comments: [
{
attachmentId: '1',

View file

@ -6,33 +6,23 @@
*/
import type {
Logger,
SavedObject,
SavedObjectReference,
SavedObjectsBulkResponse,
SavedObjectsBulkUpdateResponse,
SavedObjectsClientContract,
SavedObjectsFindResponse,
SavedObjectsUpdateOptions,
SavedObjectsUpdateResponse,
} from '@kbn/core/server';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { KueryNode } from '@kbn/es-query';
import type {
AttachmentTotals,
AttributesTypeAlerts,
CommentAttributes as AttachmentAttributes,
CommentAttributesWithoutRefs as AttachmentAttributesWithoutRefs,
CommentPatchAttributes as AttachmentPatchAttributes,
} from '../../../common/api';
import { CommentType } from '../../../common/api';
import {
CASE_COMMENT_SAVED_OBJECT,
CASE_SAVED_OBJECT,
MAX_DOCS_PER_PAGE,
} from '../../../common/constants';
import type { ClientArgs } from '..';
import { CASE_COMMENT_SAVED_OBJECT, CASE_SAVED_OBJECT } from '../../../common/constants';
import { buildFilter, combineFilters } from '../../client/utils';
import { defaultSortField } from '../../common/utils';
import type { AggregationResponse } from '../../client/metrics/types';
@ -42,15 +32,10 @@ import {
injectAttachmentSOAttributesFromRefsForPatch,
} from '../so_references';
import type { SavedObjectFindOptionsKueryNode } from '../../common/types';
import type { PersistableStateAttachmentTypeRegistry } from '../../attachment_framework/persistable_state_registry';
import type { IndexRefresh } from '../types';
import type { AttachedToCaseArgs, GetAttachmentArgs, ServiceContext } from './types';
import { AttachmentGetter } from './operations/get';
interface AttachedToCaseArgs extends ClientArgs {
caseId: string;
filter?: KueryNode;
}
type GetAllAlertsAttachToCaseArgs = AttachedToCaseArgs;
type AlertsAttachedToCaseArgs = AttachedToCaseArgs;
interface AttachmentsAttachedToCaseArgs extends AttachedToCaseArgs {
@ -62,19 +47,15 @@ interface CountActionsAttachedToCaseArgs extends AttachedToCaseArgs {
aggregations: Record<string, estypes.AggregationsAggregationContainer>;
}
interface GetAttachmentArgs extends ClientArgs {
attachmentId: string;
}
interface DeleteAttachmentArgs extends GetAttachmentArgs, IndexRefresh {}
interface CreateAttachmentArgs extends ClientArgs, IndexRefresh {
interface CreateAttachmentArgs extends IndexRefresh {
attributes: AttachmentAttributes;
references: SavedObjectReference[];
id: string;
}
interface BulkCreateAttachments extends ClientArgs, IndexRefresh {
interface BulkCreateAttachments extends IndexRefresh {
attachments: Array<{
attributes: AttachmentAttributes;
references: SavedObjectReference[];
@ -88,60 +69,28 @@ interface UpdateArgs {
options?: Omit<SavedObjectsUpdateOptions<AttachmentAttributes>, 'upsert'>;
}
export type UpdateAttachmentArgs = UpdateArgs & ClientArgs;
export type UpdateAttachmentArgs = UpdateArgs;
interface BulkUpdateAttachmentArgs extends ClientArgs, IndexRefresh {
interface BulkUpdateAttachmentArgs extends IndexRefresh {
comments: UpdateArgs[];
}
export class AttachmentService {
constructor(
private readonly log: Logger,
private readonly persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry
) {}
private readonly _getter: AttachmentGetter;
public async getAttachmentIdsForCases({
caseIds,
unsecuredSavedObjectsClient,
}: {
caseIds: string[];
unsecuredSavedObjectsClient: SavedObjectsClientContract;
}) {
try {
this.log.debug(`Attempting to retrieve attachments associated with cases: [${caseIds}]`);
constructor(private readonly context: ServiceContext) {
this._getter = new AttachmentGetter(context);
}
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 get getter() {
return this._getter;
}
public async countAlertsAttachedToCase(
params: AlertsAttachedToCaseArgs
): Promise<number | undefined> {
try {
this.log.debug(`Attempting to count alerts for case id ${params.caseId}`);
this.context.log.debug(`Attempting to count alerts for case id ${params.caseId}`);
const res = await this.executeCaseAggregations<{ alerts: { value: number } }>({
...params,
attachmentType: CommentType.alert,
@ -150,7 +99,7 @@ export class AttachmentService {
return res?.alerts?.value;
} catch (error) {
this.log.error(`Error while counting alerts for case id ${params.caseId}: ${error}`);
this.context.log.error(`Error while counting alerts for case id ${params.caseId}: ${error}`);
throw error;
}
}
@ -167,7 +116,7 @@ export class AttachmentService {
public async valueCountAlertsAttachedToCase(params: AlertsAttachedToCaseArgs): Promise<number> {
try {
this.log.debug(`Attempting to value count alerts for case id ${params.caseId}`);
this.context.log.debug(`Attempting to value count alerts for case id ${params.caseId}`);
const res = await this.executeCaseAggregations<{ alerts: { value: number } }>({
...params,
attachmentType: CommentType.alert,
@ -176,47 +125,9 @@ export class AttachmentService {
return res?.alerts?.value ?? 0;
} catch (error) {
this.log.error(`Error while value counting alerts for case id ${params.caseId}: ${error}`);
throw error;
}
}
/**
* Retrieves all the alerts attached to a case.
*/
public async getAllAlertsAttachToCase({
unsecuredSavedObjectsClient,
caseId,
filter,
}: GetAllAlertsAttachToCaseArgs): Promise<Array<SavedObject<AttributesTypeAlerts>>> {
try {
this.log.debug(`Attempting to GET all alerts for case id ${caseId}`);
const alertsFilter = buildFilter({
filters: [CommentType.alert],
field: 'type',
operator: 'or',
type: CASE_COMMENT_SAVED_OBJECT,
});
const combinedFilter = combineFilters([alertsFilter, filter]);
const finder = unsecuredSavedObjectsClient.createPointInTimeFinder<AttributesTypeAlerts>({
type: CASE_COMMENT_SAVED_OBJECT,
hasReference: { type: CASE_SAVED_OBJECT, id: caseId },
sortField: 'created_at',
sortOrder: 'asc',
filter: combinedFilter,
perPage: MAX_DOCS_PER_PAGE,
});
let result: Array<SavedObject<AttributesTypeAlerts>> = [];
for await (const userActionSavedObject of finder.find()) {
result = result.concat(userActionSavedObject.saved_objects);
}
return result;
} catch (error) {
this.log.error(`Error on GET all alerts for case id ${caseId}: ${error}`);
this.context.log.error(
`Error while value counting alerts for case id ${params.caseId}: ${error}`
);
throw error;
}
}
@ -225,14 +136,13 @@ export class AttachmentService {
* Executes the aggregations against a type of attachment attached to a case.
*/
public async executeCaseAggregations<Agg extends AggregationResponse = AggregationResponse>({
unsecuredSavedObjectsClient,
caseId,
filter,
aggregations,
attachmentType,
}: AttachmentsAttachedToCaseArgs): Promise<Agg | undefined> {
try {
this.log.debug(`Attempting to aggregate for case id ${caseId}`);
this.context.log.debug(`Attempting to aggregate for case id ${caseId}`);
const attachmentFilter = buildFilter({
filters: attachmentType,
field: 'type',
@ -242,7 +152,10 @@ export class AttachmentService {
const combinedFilter = combineFilters([attachmentFilter, filter]);
const response = await unsecuredSavedObjectsClient.find<AttachmentAttributes, Agg>({
const response = await this.context.unsecuredSavedObjectsClient.find<
AttachmentAttributes,
Agg
>({
type: CASE_COMMENT_SAVED_OBJECT,
hasReference: { type: CASE_SAVED_OBJECT, id: caseId },
page: 1,
@ -254,7 +167,7 @@ export class AttachmentService {
return response.aggregations;
} catch (error) {
this.log.error(`Error while executing aggregation for case id ${caseId}: ${error}`);
this.context.log.error(`Error while executing aggregation for case id ${caseId}: ${error}`);
throw error;
}
}
@ -266,133 +179,114 @@ export class AttachmentService {
params: CountActionsAttachedToCaseArgs
): Promise<AggregationResponse | undefined> {
try {
this.log.debug(`Attempting to count actions for case id ${params.caseId}`);
this.context.log.debug(`Attempting to count actions for case id ${params.caseId}`);
return await this.executeCaseAggregations({ ...params, attachmentType: CommentType.actions });
} catch (error) {
this.log.error(`Error while counting actions for case id ${params.caseId}: ${error}`);
this.context.log.error(`Error while counting actions for case id ${params.caseId}: ${error}`);
throw error;
}
}
public async get({
unsecuredSavedObjectsClient,
attachmentId,
}: GetAttachmentArgs): Promise<SavedObject<AttachmentAttributes>> {
public async delete({ attachmentId, refresh }: DeleteAttachmentArgs) {
try {
this.log.debug(`Attempting to GET attachment ${attachmentId}`);
const res = await unsecuredSavedObjectsClient.get<AttachmentAttributesWithoutRefs>(
this.context.log.debug(`Attempting to DELETE attachment ${attachmentId}`);
return await this.context.unsecuredSavedObjectsClient.delete(
CASE_COMMENT_SAVED_OBJECT,
attachmentId
attachmentId,
{
refresh,
}
);
return injectAttachmentSOAttributesFromRefs(res, this.persistableStateAttachmentTypeRegistry);
} catch (error) {
this.log.error(`Error on GET attachment ${attachmentId}: ${error}`);
throw error;
}
}
public async delete({
unsecuredSavedObjectsClient,
attachmentId,
refresh,
}: DeleteAttachmentArgs) {
try {
this.log.debug(`Attempting to DELETE attachment ${attachmentId}`);
return await unsecuredSavedObjectsClient.delete(CASE_COMMENT_SAVED_OBJECT, attachmentId, {
refresh,
});
} catch (error) {
this.log.error(`Error on DELETE attachment ${attachmentId}: ${error}`);
this.context.log.error(`Error on DELETE attachment ${attachmentId}: ${error}`);
throw error;
}
}
public async create({
unsecuredSavedObjectsClient,
attributes,
references,
id,
refresh,
}: CreateAttachmentArgs): Promise<SavedObject<AttachmentAttributes>> {
try {
this.log.debug(`Attempting to POST a new comment`);
this.context.log.debug(`Attempting to POST a new comment`);
const { attributes: extractedAttributes, references: extractedReferences } =
extractAttachmentSORefsFromAttributes(
attributes,
references,
this.persistableStateAttachmentTypeRegistry
this.context.persistableStateAttachmentTypeRegistry
);
const attachment = await unsecuredSavedObjectsClient.create<AttachmentAttributesWithoutRefs>(
CASE_COMMENT_SAVED_OBJECT,
extractedAttributes,
{
references: extractedReferences,
id,
refresh,
}
);
const attachment =
await this.context.unsecuredSavedObjectsClient.create<AttachmentAttributesWithoutRefs>(
CASE_COMMENT_SAVED_OBJECT,
extractedAttributes,
{
references: extractedReferences,
id,
refresh,
}
);
return injectAttachmentSOAttributesFromRefs(
attachment,
this.persistableStateAttachmentTypeRegistry
this.context.persistableStateAttachmentTypeRegistry
);
} catch (error) {
this.log.error(`Error on POST a new comment: ${error}`);
this.context.log.error(`Error on POST a new comment: ${error}`);
throw error;
}
}
public async bulkCreate({
unsecuredSavedObjectsClient,
attachments,
refresh,
}: BulkCreateAttachments): Promise<SavedObjectsBulkResponse<AttachmentAttributes>> {
try {
this.log.debug(`Attempting to bulk create attachments`);
const res = await unsecuredSavedObjectsClient.bulkCreate<AttachmentAttributesWithoutRefs>(
attachments.map((attachment) => {
const { attributes: extractedAttributes, references: extractedReferences } =
extractAttachmentSORefsFromAttributes(
attachment.attributes,
attachment.references,
this.persistableStateAttachmentTypeRegistry
);
this.context.log.debug(`Attempting to bulk create attachments`);
const res =
await this.context.unsecuredSavedObjectsClient.bulkCreate<AttachmentAttributesWithoutRefs>(
attachments.map((attachment) => {
const { attributes: extractedAttributes, references: extractedReferences } =
extractAttachmentSORefsFromAttributes(
attachment.attributes,
attachment.references,
this.context.persistableStateAttachmentTypeRegistry
);
return {
type: CASE_COMMENT_SAVED_OBJECT,
...attachment,
attributes: extractedAttributes,
references: extractedReferences,
};
}),
{ refresh }
);
return {
type: CASE_COMMENT_SAVED_OBJECT,
...attachment,
attributes: extractedAttributes,
references: extractedReferences,
};
}),
{ refresh }
);
return {
saved_objects: res.saved_objects.map((so) => {
return injectAttachmentSOAttributesFromRefs(
so,
this.persistableStateAttachmentTypeRegistry
this.context.persistableStateAttachmentTypeRegistry
);
}),
};
} catch (error) {
this.log.error(`Error on bulk create attachments: ${error}`);
this.context.log.error(`Error on bulk create attachments: ${error}`);
throw error;
}
}
public async update({
unsecuredSavedObjectsClient,
attachmentId,
updatedAttributes,
options,
}: UpdateAttachmentArgs): Promise<SavedObjectsUpdateResponse<AttachmentAttributes>> {
try {
this.log.debug(`Attempting to UPDATE comment ${attachmentId}`);
this.context.log.debug(`Attempting to UPDATE comment ${attachmentId}`);
const {
attributes: extractedAttributes,
@ -401,165 +295,116 @@ export class AttachmentService {
} = extractAttachmentSORefsFromAttributes(
updatedAttributes,
options?.references ?? [],
this.persistableStateAttachmentTypeRegistry
this.context.persistableStateAttachmentTypeRegistry
);
const shouldUpdateRefs = extractedReferences.length > 0 || didDeleteOperation;
const res = await unsecuredSavedObjectsClient.update<AttachmentAttributesWithoutRefs>(
CASE_COMMENT_SAVED_OBJECT,
attachmentId,
extractedAttributes,
{
...options,
/**
* If options?.references are undefined and there is no field to move to the refs
* then the extractedReferences will be an empty array. If we pass the empty array
* on the update then all previously refs will be removed. The check below is needed
* to prevent this.
*/
references: shouldUpdateRefs ? extractedReferences : undefined,
}
);
const res =
await this.context.unsecuredSavedObjectsClient.update<AttachmentAttributesWithoutRefs>(
CASE_COMMENT_SAVED_OBJECT,
attachmentId,
extractedAttributes,
{
...options,
/**
* If options?.references are undefined and there is no field to move to the refs
* then the extractedReferences will be an empty array. If we pass the empty array
* on the update then all previously refs will be removed. The check below is needed
* to prevent this.
*/
references: shouldUpdateRefs ? extractedReferences : undefined,
}
);
return injectAttachmentSOAttributesFromRefsForPatch(
updatedAttributes,
res,
this.persistableStateAttachmentTypeRegistry
this.context.persistableStateAttachmentTypeRegistry
);
} catch (error) {
this.log.error(`Error on UPDATE comment ${attachmentId}: ${error}`);
this.context.log.error(`Error on UPDATE comment ${attachmentId}: ${error}`);
throw error;
}
}
public async bulkUpdate({
unsecuredSavedObjectsClient,
comments,
refresh,
}: BulkUpdateAttachmentArgs): Promise<SavedObjectsBulkUpdateResponse<AttachmentAttributes>> {
try {
this.log.debug(
this.context.log.debug(
`Attempting to UPDATE comments ${comments.map((c) => c.attachmentId).join(', ')}`
);
const res = await unsecuredSavedObjectsClient.bulkUpdate<AttachmentAttributesWithoutRefs>(
comments.map((c) => {
const {
attributes: extractedAttributes,
references: extractedReferences,
didDeleteOperation,
} = extractAttachmentSORefsFromAttributes(
c.updatedAttributes,
c.options?.references ?? [],
this.persistableStateAttachmentTypeRegistry
);
const res =
await this.context.unsecuredSavedObjectsClient.bulkUpdate<AttachmentAttributesWithoutRefs>(
comments.map((c) => {
const {
attributes: extractedAttributes,
references: extractedReferences,
didDeleteOperation,
} = extractAttachmentSORefsFromAttributes(
c.updatedAttributes,
c.options?.references ?? [],
this.context.persistableStateAttachmentTypeRegistry
);
const shouldUpdateRefs = extractedReferences.length > 0 || didDeleteOperation;
const shouldUpdateRefs = extractedReferences.length > 0 || didDeleteOperation;
return {
...c.options,
type: CASE_COMMENT_SAVED_OBJECT,
id: c.attachmentId,
attributes: extractedAttributes,
/* If c.options?.references are undefined and there is no field to move to the refs
* then the extractedAttributes will be an empty array. If we pass the empty array
* on the update then all previously refs will be removed. The check below is needed
* to prevent this.
*/
references: shouldUpdateRefs ? extractedReferences : undefined,
};
}),
{ refresh }
);
return {
...c.options,
type: CASE_COMMENT_SAVED_OBJECT,
id: c.attachmentId,
attributes: extractedAttributes,
/* If c.options?.references are undefined and there is no field to move to the refs
* then the extractedAttributes will be an empty array. If we pass the empty array
* on the update then all previously refs will be removed. The check below is needed
* to prevent this.
*/
references: shouldUpdateRefs ? extractedReferences : undefined,
};
}),
{ refresh }
);
return {
saved_objects: res.saved_objects.map((so, index) => {
return injectAttachmentSOAttributesFromRefsForPatch(
comments[index].updatedAttributes,
so,
this.persistableStateAttachmentTypeRegistry
this.context.persistableStateAttachmentTypeRegistry
);
}),
};
} catch (error) {
this.log.error(
this.context.log.error(
`Error on UPDATE comments ${comments.map((c) => c.attachmentId).join(', ')}: ${error}`
);
throw error;
}
}
public async getCaseCommentStats({
unsecuredSavedObjectsClient,
caseIds,
}: {
unsecuredSavedObjectsClient: SavedObjectsClientContract;
caseIds: string[];
}): Promise<Map<string, AttachmentTotals>> {
if (caseIds.length <= 0) {
return new Map();
}
interface AggsResult {
references: {
caseIds: {
buckets: Array<{
key: string;
doc_count: number;
reverse: {
alerts: {
value: number;
};
comments: {
doc_count: number;
};
};
}>;
};
};
}
const res = await unsecuredSavedObjectsClient.find<unknown, AggsResult>({
hasReference: caseIds.map((id) => ({ type: CASE_SAVED_OBJECT, id })),
hasReferenceOperator: 'OR',
type: CASE_COMMENT_SAVED_OBJECT,
perPage: 0,
aggs: AttachmentService.buildCommentStatsAggs(caseIds),
});
return (
res.aggregations?.references.caseIds.buckets.reduce((acc, idBucket) => {
acc.set(idBucket.key, {
userComments: idBucket.reverse.comments.doc_count,
alerts: idBucket.reverse.alerts.value,
});
return acc;
}, new Map<string, AttachmentTotals>()) ?? new Map()
);
}
public async find({
unsecuredSavedObjectsClient,
options,
}: {
unsecuredSavedObjectsClient: SavedObjectsClientContract;
options?: SavedObjectFindOptionsKueryNode;
}): Promise<SavedObjectsFindResponse<AttachmentAttributes>> {
try {
this.log.debug(`Attempting to find comments`);
const res = await unsecuredSavedObjectsClient.find<AttachmentAttributesWithoutRefs>({
sortField: defaultSortField,
...options,
type: CASE_COMMENT_SAVED_OBJECT,
});
this.context.log.debug(`Attempting to find comments`);
const res =
await this.context.unsecuredSavedObjectsClient.find<AttachmentAttributesWithoutRefs>({
sortField: defaultSortField,
...options,
type: CASE_COMMENT_SAVED_OBJECT,
});
return {
...res,
saved_objects: res.saved_objects.map((so) => {
const injectedSO = injectAttachmentSOAttributesFromRefs(
so,
this.persistableStateAttachmentTypeRegistry
this.context.persistableStateAttachmentTypeRegistry
);
return {
@ -569,47 +414,8 @@ export class AttachmentService {
}),
};
} catch (error) {
this.log.error(`Error on find comments: ${error}`);
this.context.log.error(`Error on find comments: ${error}`);
throw error;
}
}
private static buildCommentStatsAggs(
ids: string[]
): Record<string, estypes.AggregationsAggregationContainer> {
return {
references: {
nested: {
path: `${CASE_COMMENT_SAVED_OBJECT}.references`,
},
aggregations: {
caseIds: {
terms: {
field: `${CASE_COMMENT_SAVED_OBJECT}.references.id`,
size: ids.length,
},
aggregations: {
reverse: {
reverse_nested: {},
aggregations: {
alerts: {
cardinality: {
field: `${CASE_COMMENT_SAVED_OBJECT}.attributes.alertId`,
},
},
comments: {
filter: {
term: {
[`${CASE_COMMENT_SAVED_OBJECT}.attributes.type`]: CommentType.user,
},
},
},
},
},
},
},
},
},
};
}
}

View file

@ -0,0 +1,250 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { SavedObject } from '@kbn/core/server';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import {
CASE_COMMENT_SAVED_OBJECT,
CASE_SAVED_OBJECT,
MAX_DOCS_PER_PAGE,
} from '../../../../common/constants';
import { buildFilter, combineFilters } from '../../../client/utils';
import type {
AttachmentTotals,
AttributesTypeAlerts,
CommentAttributes as AttachmentAttributes,
CommentAttributesWithoutRefs as AttachmentAttributesWithoutRefs,
CommentAttributes,
} from '../../../../common/api';
import { CommentType } from '../../../../common/api';
import type {
AttachedToCaseArgs,
BulkOptionalAttributes,
GetAttachmentArgs,
ServiceContext,
} from '../types';
import {
injectAttachmentAttributesAndHandleErrors,
injectAttachmentSOAttributesFromRefs,
} from '../../so_references';
type GetAllAlertsAttachToCaseArgs = AttachedToCaseArgs;
export class AttachmentGetter {
constructor(private readonly context: ServiceContext) {}
public async bulkGet(
attachmentIds: string[]
): Promise<BulkOptionalAttributes<CommentAttributes>> {
try {
this.context.log.debug(
`Attempting to retrieve attachments with ids: ${attachmentIds.join()}`
);
const response =
await this.context.unsecuredSavedObjectsClient.bulkGet<AttachmentAttributesWithoutRefs>(
attachmentIds.map((id) => ({ id, type: CASE_COMMENT_SAVED_OBJECT }))
);
return {
saved_objects: response.saved_objects.map((so) =>
injectAttachmentAttributesAndHandleErrors(
so,
this.context.persistableStateAttachmentTypeRegistry
)
),
};
} catch (error) {
this.context.log.error(
`Error retrieving attachments with ids ${attachmentIds.join()}: ${error}`
);
throw error;
}
}
public async getAttachmentIdsForCases({ caseIds }: { caseIds: string[] }) {
try {
this.context.log.debug(
`Attempting to retrieve attachments associated with cases: [${caseIds}]`
);
const finder = this.context.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.context.log.error(
`Error retrieving attachments associated with cases: [${caseIds}]: ${error}`
);
throw error;
}
}
/**
* Retrieves all the alerts attached to a case.
*/
public async getAllAlertsAttachToCase({
caseId,
filter,
}: GetAllAlertsAttachToCaseArgs): Promise<Array<SavedObject<AttributesTypeAlerts>>> {
try {
this.context.log.debug(`Attempting to GET all alerts for case id ${caseId}`);
const alertsFilter = buildFilter({
filters: [CommentType.alert],
field: 'type',
operator: 'or',
type: CASE_COMMENT_SAVED_OBJECT,
});
const combinedFilter = combineFilters([alertsFilter, filter]);
const finder =
this.context.unsecuredSavedObjectsClient.createPointInTimeFinder<AttributesTypeAlerts>({
type: CASE_COMMENT_SAVED_OBJECT,
hasReference: { type: CASE_SAVED_OBJECT, id: caseId },
sortField: 'created_at',
sortOrder: 'asc',
filter: combinedFilter,
perPage: MAX_DOCS_PER_PAGE,
});
let result: Array<SavedObject<AttributesTypeAlerts>> = [];
for await (const userActionSavedObject of finder.find()) {
result = result.concat(userActionSavedObject.saved_objects);
}
return result;
} catch (error) {
this.context.log.error(`Error on GET all alerts for case id ${caseId}: ${error}`);
throw error;
}
}
public async get({
attachmentId,
}: GetAttachmentArgs): Promise<SavedObject<AttachmentAttributes>> {
try {
this.context.log.debug(`Attempting to GET attachment ${attachmentId}`);
const res =
await this.context.unsecuredSavedObjectsClient.get<AttachmentAttributesWithoutRefs>(
CASE_COMMENT_SAVED_OBJECT,
attachmentId
);
return injectAttachmentSOAttributesFromRefs(
res,
this.context.persistableStateAttachmentTypeRegistry
);
} catch (error) {
this.context.log.error(`Error on GET attachment ${attachmentId}: ${error}`);
throw error;
}
}
public async getCaseCommentStats({
caseIds,
}: {
caseIds: string[];
}): Promise<Map<string, AttachmentTotals>> {
if (caseIds.length <= 0) {
return new Map();
}
interface AggsResult {
references: {
caseIds: {
buckets: Array<{
key: string;
doc_count: number;
reverse: {
alerts: {
value: number;
};
comments: {
doc_count: number;
};
};
}>;
};
};
}
const res = await this.context.unsecuredSavedObjectsClient.find<unknown, AggsResult>({
hasReference: caseIds.map((id) => ({ type: CASE_SAVED_OBJECT, id })),
hasReferenceOperator: 'OR',
type: CASE_COMMENT_SAVED_OBJECT,
perPage: 0,
aggs: AttachmentGetter.buildCommentStatsAggs(caseIds),
});
return (
res.aggregations?.references.caseIds.buckets.reduce((acc, idBucket) => {
acc.set(idBucket.key, {
userComments: idBucket.reverse.comments.doc_count,
alerts: idBucket.reverse.alerts.value,
});
return acc;
}, new Map<string, AttachmentTotals>()) ?? new Map()
);
}
private static buildCommentStatsAggs(
ids: string[]
): Record<string, estypes.AggregationsAggregationContainer> {
return {
references: {
nested: {
path: `${CASE_COMMENT_SAVED_OBJECT}.references`,
},
aggregations: {
caseIds: {
terms: {
field: `${CASE_COMMENT_SAVED_OBJECT}.references.id`,
size: ids.length,
},
aggregations: {
reverse: {
reverse_nested: {},
aggregations: {
alerts: {
cardinality: {
field: `${CASE_COMMENT_SAVED_OBJECT}.attributes.alertId`,
},
},
comments: {
filter: {
term: {
[`${CASE_COMMENT_SAVED_OBJECT}.attributes.type`]: CommentType.user,
},
},
},
},
},
},
},
},
},
};
}
}

View file

@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type {
Logger,
SavedObject,
SavedObjectsBulkResponse,
SavedObjectsClientContract,
} from '@kbn/core/server';
import type { KueryNode } from '@kbn/es-query';
import type { PersistableStateAttachmentTypeRegistry } from '../../attachment_framework/persistable_state_registry';
import type { PartialField } from '../../types';
export interface ServiceContext {
log: Logger;
persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry;
unsecuredSavedObjectsClient: SavedObjectsClientContract;
}
export interface AttachedToCaseArgs {
caseId: string;
filter?: KueryNode;
}
export interface GetAttachmentArgs {
attachmentId: string;
}
export type OptionalAttributes<T> = PartialField<SavedObject<T>, 'attributes'>;
export interface BulkOptionalAttributes<T>
extends Omit<SavedObjectsBulkResponse<T>, 'saved_objects'> {
saved_objects: Array<OptionalAttributes<T>>;
}

View file

@ -153,10 +153,11 @@ describe('CasesService', () => {
const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
const mockLogger = loggerMock.create();
const persistableStateAttachmentTypeRegistry = new PersistableStateAttachmentTypeRegistry();
const attachmentService = new AttachmentService(
mockLogger,
persistableStateAttachmentTypeRegistry
);
const attachmentService = new AttachmentService({
log: mockLogger,
persistableStateAttachmentTypeRegistry,
unsecuredSavedObjectsClient,
});
let service: CasesService;

View file

@ -226,8 +226,7 @@ export class CasesService {
return accMap;
}, new Map<string, SavedObjectsFindResult<CaseAttributes>>());
const commentTotals = await this.attachmentService.getCaseCommentStats({
unsecuredSavedObjectsClient: this.unsecuredSavedObjectsClient,
const commentTotals = await this.attachmentService.getter.getCaseCommentStats({
caseIds: Array.from(casesMap.keys()),
});
@ -411,7 +410,6 @@ export class CasesService {
this.log.debug(`Attempting to GET all comments internal for id ${JSON.stringify(id)}`);
if (options?.page !== undefined || options?.perPage !== undefined) {
return this.attachmentService.find({
unsecuredSavedObjectsClient: this.unsecuredSavedObjectsClient,
options: {
sortField: defaultSortField,
...options,
@ -420,7 +418,6 @@ export class CasesService {
}
return this.attachmentService.find({
unsecuredSavedObjectsClient: this.unsecuredSavedObjectsClient,
options: {
page: 1,
perPage: MAX_DOCS_PER_PAGE,
@ -521,7 +518,6 @@ export class CasesService {
username,
full_name: user.full_name ?? null,
email: user.email ?? null,
// TODO: verify that adding a new field is ok, shouldn't be a breaking change
profile_uid: user.profile_uid,
};
}) ?? []

View file

@ -14,6 +14,7 @@ import type {
ConnectorMappingsService,
AttachmentService,
} from '.';
import type { AttachmentGetter } from './attachments/operations/get';
import type { LicensingService } from './licensing';
import type { EmailNotificationService } from './notifications/email_notification_service';
import type { UserActionPersister } from './user_actions/operations/create';
@ -24,6 +25,12 @@ interface UserActionServiceOperations {
finder: CaseUserActionFinderServiceMock;
}
interface AttachmentServiceOperations {
getter: AttachmentGetterServiceMock;
}
export type AttachmentGetterServiceMock = jest.Mocked<AttachmentGetter>;
export type CaseServiceMock = jest.Mocked<CasesService>;
export type CaseConfigureServiceMock = jest.Mocked<CaseConfigureService>;
export type ConnectorMappingsServiceMock = jest.Mocked<ConnectorMappingsService>;
@ -33,7 +40,7 @@ export type CaseUserActionServiceMock = jest.Mocked<
export type CaseUserActionPersisterServiceMock = jest.Mocked<UserActionPersister>;
export type CaseUserActionFinderServiceMock = jest.Mocked<UserActionFinder>;
export type AlertServiceMock = jest.Mocked<AlertService>;
export type AttachmentServiceMock = jest.Mocked<AttachmentService>;
export type AttachmentServiceMock = jest.Mocked<AttachmentService & AttachmentServiceOperations>;
export type LicensingServiceMock = jest.Mocked<LicensingService>;
export type NotificationServiceMock = jest.Mocked<EmailNotificationService>;
@ -134,22 +141,33 @@ export const createAlertServiceMock = (): AlertServiceMock => {
return service as unknown as AlertServiceMock;
};
export const createAttachmentServiceMock = (): AttachmentServiceMock => {
const service: PublicMethodsOf<AttachmentService> = {
const createAttachmentGetterServiceMock = (): AttachmentGetterServiceMock => {
const service: PublicMethodsOf<AttachmentGetter> = {
get: jest.fn(),
bulkGet: jest.fn(),
getAllAlertsAttachToCase: jest.fn(),
getCaseCommentStats: jest.fn(),
getAttachmentIdsForCases: jest.fn(),
};
return service as unknown as AttachmentGetterServiceMock;
};
type FakeAttachmentService = PublicMethodsOf<AttachmentService> & AttachmentServiceOperations;
export const createAttachmentServiceMock = (): AttachmentServiceMock => {
const service: FakeAttachmentService = {
getter: createAttachmentGetterServiceMock(),
delete: jest.fn(),
create: jest.fn(),
bulkCreate: jest.fn(),
update: jest.fn(),
bulkUpdate: jest.fn(),
find: jest.fn(),
getAllAlertsAttachToCase: jest.fn(),
countAlertsAttachedToCase: jest.fn(),
executeCaseActionsAggregations: jest.fn(),
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

@ -22,6 +22,7 @@ import {
} from '../attachment_framework/so_references';
import { EXTERNAL_REFERENCE_REF_NAME } from '../common/constants';
import { isCommentRequestTypeExternalReferenceSO } from '../common/utils';
import type { PartialField } from '../types';
import { SOReferenceExtractor } from './so_reference_extractor';
export const getAttachmentSOExtractor = (attachment: Partial<CommentRequest>) => {
@ -38,6 +39,30 @@ export const getAttachmentSOExtractor = (attachment: Partial<CommentRequest>) =>
return new SOReferenceExtractor(fieldsToExtract);
};
type OptionalAttributes<T> = PartialField<SavedObject<T>, 'attributes'>;
/**
* This function should be used when the attributes field could be undefined. Specifically when
* performing a bulkGet within the core saved object library. If one of the requested ids does not exist in elasticsearch
* then the error field will be set and attributes will be undefined.
*/
export const injectAttachmentAttributesAndHandleErrors = (
savedObject: OptionalAttributes<CommentAttributesWithoutRefs>,
persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry
): OptionalAttributes<CommentAttributes> => {
if (!hasAttributes(savedObject)) {
// we don't actually have an attributes field here so the type doesn't matter, this cast is to get the types to stop
// complaining though
return savedObject as OptionalAttributes<CommentAttributes>;
}
return injectAttachmentSOAttributesFromRefs(savedObject, persistableStateAttachmentTypeRegistry);
};
const hasAttributes = <T>(savedObject: OptionalAttributes<T>): savedObject is SavedObject<T> => {
return savedObject.error == null && savedObject.attributes != null;
};
export const injectAttachmentSOAttributesFromRefs = (
savedObject: SavedObject<CommentAttributesWithoutRefs>,
persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry

View file

@ -56,3 +56,5 @@ export interface PluginStartContract {
export interface PluginSetupContract {
attachmentFramework: AttachmentFramework;
}
export type PartialField<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;

View file

@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type SuperTest from 'supertest';
import {
BulkGetAttachmentsResponse,
getCaseBulkGetAttachmentsUrl,
} from '@kbn/cases-plugin/common/api';
import { User } from './authentication/types';
import { superUser } from './authentication/users';
import { getSpaceUrlPrefix } from './utils';
export const bulkGetAttachments = async ({
supertest,
attachmentIds,
caseId,
expectedHttpCode = 200,
auth = { user: superUser, space: null },
}: {
supertest: SuperTest.SuperTest<SuperTest.Test>;
attachmentIds: string[];
caseId: string;
auth?: { user: User; space: string | null };
expectedHttpCode?: number;
}): Promise<BulkGetAttachmentsResponse> => {
const { body: comments } = await supertest
.post(`${getSpaceUrlPrefix(auth.space)}${getCaseBulkGetAttachmentsUrl(caseId)}`)
.send({ ids: attachmentIds })
.set('kbn-xsrf', 'abc')
.auth(auth.user.username, auth.user.password)
.expect(expectedHttpCode);
return comments;
};

View file

@ -46,6 +46,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => {
loadTestFile(require.resolve('./internal/bulk_create_attachments'));
loadTestFile(require.resolve('./internal/bulk_get_cases'));
loadTestFile(require.resolve('./internal/bulk_get_attachments'));
loadTestFile(require.resolve('./internal/get_connectors'));
/**

View file

@ -0,0 +1,459 @@
/*
* 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 {
AttributesTypeExternalReference,
AttributesTypeExternalReferenceSO,
CaseResponse,
CommentResponseTypePersistableState,
} from '@kbn/cases-plugin/common/api';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
import {
postCaseReq,
getPostCaseRequest,
postCommentUserReq,
postCommentAlertReq,
persistableStateAttachment,
postExternalReferenceSOReq,
postExternalReferenceESReq,
} from '../../../../common/lib/mock';
import {
deleteAllCaseItems,
createCase,
createComment,
bulkCreateAttachments,
ensureSavedObjectIsAuthorized,
} from '../../../../common/lib/utils';
import { bulkGetAttachments } from '../../../../common/lib/attachments';
import {
globalRead,
noKibanaPrivileges,
obsOnly,
obsOnlyRead,
obsSec,
obsSecRead,
secOnly,
secOnlyRead,
superUser,
} from '../../../../common/lib/authentication/users';
// eslint-disable-next-line import/no-default-export
export default ({ getService }: FtrProviderContext): void => {
const supertest = getService('supertest');
const es = getService('es');
describe('bulk_get_attachments', () => {
describe('setup using two comments', () => {
let updatedCase: CaseResponse;
before(async () => {
const postedCase = await createCase(supertest, postCaseReq);
updatedCase = await bulkCreateAttachments({
caseId: postedCase.id,
params: [postCommentUserReq, postCommentAlertReq],
supertest,
});
});
after(async () => {
await deleteAllCaseItems(es);
});
it('should retrieve a single attachment', async () => {
const response = await bulkGetAttachments({
attachmentIds: [updatedCase.comments![0].id],
caseId: updatedCase.id,
supertest,
});
expect(response.attachments.length).to.be(1);
expect(response.errors.length).to.be(0);
expect(response.attachments[0].id).to.eql(updatedCase.comments![0].id);
});
it('should retrieve a multiple attachments', async () => {
const response = await bulkGetAttachments({
attachmentIds: [updatedCase.comments![0].id, updatedCase.comments![1].id],
caseId: updatedCase.id,
supertest,
});
expect(response.attachments.length).to.be(2);
expect(response.errors.length).to.be(0);
expect(response.attachments[0].id).to.eql(updatedCase.comments![0].id);
expect(response.attachments[1].id).to.eql(updatedCase.comments![1].id);
});
it('returns an empty array when no ids are requested', async () => {
const { attachments, errors } = await bulkGetAttachments({
attachmentIds: [],
caseId: updatedCase.id,
supertest,
expectedHttpCode: 200,
});
expect(attachments.length).to.be(0);
expect(errors.length).to.be(0);
});
it('returns a 400 when more than 10k ids are requested', async () => {
await bulkGetAttachments({
attachmentIds: Array.from(Array(10001).keys()).map((item) => item.toString()),
caseId: updatedCase.id,
supertest,
expectedHttpCode: 400,
});
});
it('populates the errors field with attachments that could not be found', async () => {
const response = await bulkGetAttachments({
attachmentIds: [updatedCase.comments![0].id, 'does-not-exist'],
caseId: updatedCase.id,
supertest,
expectedHttpCode: 200,
});
expect(response.attachments.length).to.be(1);
expect(response.errors.length).to.be(1);
expect(response.errors[0]).to.eql({
error: 'Not Found',
message: 'Saved object [cases-comments/does-not-exist] not found',
status: 404,
attachmentId: 'does-not-exist',
});
});
});
describe('inject references into attributes', () => {
it('should inject the persistable state attachment references into the attributes', async () => {
const postedCase = await createCase(supertest, postCaseReq);
const patchedCase = await createComment({
supertest,
caseId: postedCase.id,
params: persistableStateAttachment,
});
const response = await bulkGetAttachments({
attachmentIds: [patchedCase.comments![0].id],
caseId: patchedCase.id,
supertest,
});
const persistableState = response.attachments[0] as CommentResponseTypePersistableState;
expect(persistableState.persistableStateAttachmentState).to.eql(
persistableStateAttachment.persistableStateAttachmentState
);
});
it("should inject saved object external reference style attachment's references into the attributes", async () => {
const postedCase = await createCase(supertest, postCaseReq);
const patchedCase = await createComment({
supertest,
caseId: postedCase.id,
params: postExternalReferenceSOReq,
});
const response = await bulkGetAttachments({
attachmentIds: [patchedCase.comments![0].id],
caseId: patchedCase.id,
supertest,
});
const externalRefSO = response.attachments[0] as AttributesTypeExternalReferenceSO;
expect(externalRefSO.externalReferenceId).to.eql(
postExternalReferenceSOReq.externalReferenceId
);
expect(externalRefSO.externalReferenceStorage.soType).to.eql(
postExternalReferenceSOReq.externalReferenceStorage.soType
);
expect(externalRefSO.externalReferenceStorage.type).to.eql(
postExternalReferenceSOReq.externalReferenceStorage.type
);
});
it("should inject the elasticsearch external reference style attachment's references into the attributes", async () => {
const postedCase = await createCase(supertest, postCaseReq);
const patchedCase = await createComment({
supertest,
caseId: postedCase.id,
params: postExternalReferenceESReq,
});
const response = await bulkGetAttachments({
attachmentIds: [patchedCase.comments![0].id],
caseId: patchedCase.id,
supertest,
});
const externalRefES = response.attachments[0] as AttributesTypeExternalReference;
expect(externalRefES.externalReferenceId).to.eql(
postExternalReferenceESReq.externalReferenceId
);
expect(externalRefES.externalReferenceStorage.type).to.eql(
postExternalReferenceESReq.externalReferenceStorage.type
);
});
});
describe('rbac', () => {
const supertestWithoutAuth = getService('supertestWithoutAuth');
afterEach(async () => {
await deleteAllCaseItems(es);
});
describe('security and observability cases', () => {
let secCase: CaseResponse;
let obsCase: CaseResponse;
let secAttachmentId: string;
let obsAttachmentId: string;
beforeEach(async () => {
[secCase, obsCase] = await Promise.all([
// Create case owned by the security solution user
createCase(
supertestWithoutAuth,
getPostCaseRequest({ owner: 'securitySolutionFixture' }),
200,
{
user: superUser,
space: 'space1',
}
),
// Create case owned by the observability user
createCase(
supertestWithoutAuth,
getPostCaseRequest({ owner: 'observabilityFixture' }),
200,
{
user: superUser,
space: 'space1',
}
),
]);
[secCase, obsCase] = await Promise.all([
createComment({
supertest: supertestWithoutAuth,
caseId: secCase.id,
params: postCommentUserReq,
auth: { user: superUser, space: 'space1' },
}),
createComment({
supertest: supertestWithoutAuth,
caseId: obsCase.id,
params: { ...postCommentUserReq, owner: 'observabilityFixture' },
auth: { user: superUser, space: 'space1' },
}),
]);
secAttachmentId = secCase.comments![0].id;
obsAttachmentId = obsCase.comments![0].id;
});
it('should be able to read attachments', async () => {
for (const scenario of [
{
user: globalRead,
owners: ['securitySolutionFixture'],
caseId: secCase.id,
attachmentIds: [secAttachmentId],
},
{
user: globalRead,
owners: ['observabilityFixture'],
caseId: obsCase.id,
attachmentIds: [obsAttachmentId],
},
{
user: superUser,
owners: ['securitySolutionFixture'],
caseId: secCase.id,
attachmentIds: [secAttachmentId],
},
{
user: superUser,
owners: ['observabilityFixture'],
caseId: obsCase.id,
attachmentIds: [obsAttachmentId],
},
{
user: secOnlyRead,
owners: ['securitySolutionFixture'],
caseId: secCase.id,
attachmentIds: [secAttachmentId],
},
{
user: obsOnlyRead,
owners: ['observabilityFixture'],
caseId: obsCase.id,
attachmentIds: [obsAttachmentId],
},
{
user: obsSecRead,
owners: ['securitySolutionFixture'],
caseId: secCase.id,
attachmentIds: [secAttachmentId],
},
{
user: obsSecRead,
owners: ['observabilityFixture'],
caseId: obsCase.id,
attachmentIds: [obsAttachmentId],
},
{
user: obsSec,
owners: ['securitySolutionFixture'],
caseId: secCase.id,
attachmentIds: [secAttachmentId],
},
{
user: obsSec,
owners: ['observabilityFixture'],
caseId: obsCase.id,
attachmentIds: [obsAttachmentId],
},
{
user: secOnly,
owners: ['securitySolutionFixture'],
caseId: secCase.id,
attachmentIds: [secAttachmentId],
},
{
user: obsOnly,
owners: ['observabilityFixture'],
caseId: obsCase.id,
attachmentIds: [obsAttachmentId],
},
]) {
const { attachments, errors } = await bulkGetAttachments({
supertest: supertestWithoutAuth,
attachmentIds: scenario.attachmentIds,
caseId: scenario.caseId,
auth: { user: scenario.user, space: 'space1' },
});
const numberOfCasesThatShouldBeReturned = 1;
ensureSavedObjectIsAuthorized(
attachments,
numberOfCasesThatShouldBeReturned,
scenario.owners
);
expect(errors.length).to.be(0);
}
});
it('should return an association error when an observability attachment is requested for a security case', async () => {
const { attachments, errors } = await bulkGetAttachments({
supertest: supertestWithoutAuth,
attachmentIds: [secAttachmentId, obsAttachmentId],
caseId: secCase.id,
auth: { user: secOnlyRead, space: 'space1' },
});
expect(attachments.length).to.be(1);
expect(attachments[0].owner).to.be('securitySolutionFixture');
expect(errors.length).to.be(1);
expect(errors[0]).to.eql({
attachmentId: obsAttachmentId,
error: 'Bad Request',
message: `Attachment is not attached to case id=${secCase.id}`,
status: 400,
});
});
it('should return an authorization error when a security solution user is attempting to retrieve an observability attachment', async () => {
await bulkGetAttachments({
supertest: supertestWithoutAuth,
attachmentIds: [obsAttachmentId],
caseId: obsCase.id,
auth: { user: secOnlyRead, space: 'space1' },
expectedHttpCode: 403,
});
});
it('should return authorization error when a observability user requests a security attachment for a security case', async () => {
await bulkGetAttachments({
supertest: supertestWithoutAuth,
attachmentIds: [secAttachmentId],
caseId: secCase.id,
auth: { user: obsOnlyRead, space: 'space1' },
expectedHttpCode: 403,
});
});
it('should return an error for the observability attachment that is not associated to the case, and an error for an unknown attachment', async () => {
const { attachments, errors } = await bulkGetAttachments({
supertest: supertestWithoutAuth,
attachmentIds: [obsAttachmentId, 'does-not-exist'],
caseId: secCase.id,
auth: { user: secOnly, space: 'space1' },
});
expect(attachments.length).to.be(0);
expect(errors.length).to.be(2);
expect(errors[0]).to.eql({
error: 'Not Found',
message: 'Saved object [cases-comments/does-not-exist] not found',
status: 404,
attachmentId: 'does-not-exist',
});
expect(errors[1]).to.eql({
attachmentId: obsAttachmentId,
error: 'Bad Request',
message: `Attachment is not attached to case id=${secCase.id}`,
status: 400,
});
});
});
for (const scenario of [
{ user: noKibanaPrivileges, space: 'space1' },
{ user: secOnly, space: 'space2' },
]) {
it(`User ${scenario.user.username} with role(s) ${scenario.user.roles.join()} and space ${
scenario.space
} - should not bulk get attachments`, async () => {
// super user creates a case at the appropriate space
const newCase = await createCase(
supertestWithoutAuth,
getPostCaseRequest({ owner: 'securitySolutionFixture' }),
200,
{
user: superUser,
space: scenario.space,
}
);
const patchedCase = await createComment({
supertest: supertestWithoutAuth,
caseId: newCase.id,
params: postCommentUserReq,
auth: { user: superUser, space: scenario.space },
});
await bulkGetAttachments({
supertest: supertestWithoutAuth,
attachmentIds: [patchedCase.comments![0].id],
caseId: newCase.id,
auth: {
user: scenario.user,
space: scenario.space,
},
expectedHttpCode: 403,
});
});
}
});
});
};