Skip to content

Commit

Permalink
[Cases] Registering file attachment type (elastic#152399)
Browse files Browse the repository at this point in the history
This PR registers the file attachment type `.files` within the cases
attachment framework.

Notable changes:
- Attachment framework accepts an optional validation function for use
when a request is received for that attachment type, this allows
validating that the file metadata is correct
- Refactored the logic for enforcing that only 1000 alerts can be
attached to a case and 100 files

Issue: elastic#151933

<details><summary>cUrl request to create file attachments</summary>

```
curl --location --request POST 'http://elastic:changeme@localhost:5601/internal/cases/<case id>/attachments/_bulk_create' \
--header 'kbn-xsrf: hello' \
--header 'Content-Type: application/json' \
--data-raw '[
    {
        "type": "externalReference",
        "externalReferenceStorage": {
            "type": "savedObject",
            "soType": "file"
        },
        "externalReferenceId": "my-id",
        "externalReferenceAttachmentTypeId": ".files",
        "externalReferenceMetadata": {
            "file": {
                "name": "test_file",
                "extension": "png",
                "mimeType": "image/png",
                "createdAt": "2023-02-27T20:26:54.345Z"
            }
        },
        "owner": "cases"
    },
    {
        "type": "externalReference",
        "externalReferenceStorage": {
            "type": "savedObject",
            "soType": "file"
        },
        "externalReferenceId": "my-id",
        "externalReferenceAttachmentTypeId": ".files",
        "externalReferenceMetadata": {
            "file": {
                "name": "test_file",
                "extension": "png",
                "mimeType": "image/png",
                "createdAt": "2023-02-27T20:26:54.345Z"
            }
        },
        "owner": "cases"
    },
        {
        "type": "externalReference",
        "externalReferenceStorage": {
            "type": "savedObject",
            "soType": "file"
        },
        "externalReferenceId": "my-id",
        "externalReferenceAttachmentTypeId": ".files",
        "externalReferenceMetadata": {
            "file": {
                "name": "test_file",
                "extension": "jpeg",
                "mimeType": "image/jpeg",
                "createdAt": "2023-02-27T20:26:54.345Z"
            }
        },
        "owner": "cases"
    }
]
'
```

</details>
  • Loading branch information
jonathan-buttner authored and bmorelli25 committed Mar 10, 2023
1 parent 787e515 commit 8aaa687
Show file tree
Hide file tree
Showing 29 changed files with 1,192 additions and 75 deletions.
23 changes: 23 additions & 0 deletions x-pack/plugins/cases/common/api/cases/comment/files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import * as rt from 'io-ts';

export const FileAttachmentMetadataRt = rt.type({
files: rt.array(
rt.type({
name: rt.string,
extension: rt.string,
mimeType: rt.string,
createdAt: rt.string,
})
),
});

export type FileAttachmentMetadata = rt.TypeOf<typeof FileAttachmentMetadataRt>;

export const FILE_ATTACHMENT_TYPE = '.files';
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
*/

import * as rt from 'io-ts';
import { jsonValueRt } from '../runtime_types';
import { SavedObjectFindOptionsRt } from '../saved_object';
import { jsonValueRt } from '../../runtime_types';
import { SavedObjectFindOptionsRt } from '../../saved_object';

import { UserRt } from '../user';
import { UserRt } from '../../user';

export * from './files';

export const CommentAttributesBasicRt = rt.type({
created_at: rt.string,
Expand Down
1 change: 1 addition & 0 deletions x-pack/plugins/cases/common/constants/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import type { HttpApiTagOperation, Owner } from './types';

export const MAX_FILES_PER_CASE = 100;
export const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MiB

export const constructFilesHttpOperationTag = (owner: Owner, operation: HttpApiTagOperation) => {
Expand Down
5 changes: 5 additions & 0 deletions x-pack/plugins/cases/server/attachment_framework/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ export interface PersistableStateAttachmentTypeSetup

export interface ExternalReferenceAttachmentType {
id: string;
/**
* A function to validate data stored with the attachment type. This function should throw an error
* if the data is not in the form it expects.
*/
schemaValidator?: (data: unknown) => void;
}

export interface AttachmentFramework {
Expand Down
2 changes: 1 addition & 1 deletion x-pack/plugins/cases/server/client/attachments/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export const addComment = async (
externalReferenceAttachmentTypeRegistry,
} = clientArgs;

decodeCommentRequest(comment);
decodeCommentRequest(comment, externalReferenceAttachmentTypeRegistry);
try {
const savedObjectID = SavedObjectsUtils.generateId();

Expand Down
6 changes: 3 additions & 3 deletions x-pack/plugins/cases/server/client/attachments/bulk_create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,12 @@ export const bulkCreate = async (
fold(throwErrors(Boom.badRequest), identity)
);

const { logger, authorization, externalReferenceAttachmentTypeRegistry } = clientArgs;

attachments.forEach((attachment) => {
decodeCommentRequest(attachment);
decodeCommentRequest(attachment, externalReferenceAttachmentTypeRegistry);
});

const { logger, authorization } = clientArgs;

try {
const [attachmentsWithIds, entities]: [Array<{ id: string } & CommentRequest>, OwnerEntity[]] =
attachments.reduce<[Array<{ id: string } & CommentRequest>, OwnerEntity[]]>(
Expand Down
3 changes: 2 additions & 1 deletion x-pack/plugins/cases/server/client/attachments/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export async function update(
services: { attachmentService },
logger,
authorization,
externalReferenceAttachmentTypeRegistry,
} = clientArgs;

try {
Expand All @@ -39,7 +40,7 @@ export async function update(
...queryRestAttributes
} = queryParams;

decodeCommentRequest(queryRestAttributes);
decodeCommentRequest(queryRestAttributes, externalReferenceAttachmentTypeRegistry);

const myComment = await attachmentService.getter.get({
attachmentId: queryCommentId,
Expand Down
20 changes: 17 additions & 3 deletions x-pack/plugins/cases/server/client/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,12 @@ import {
isCommentRequestTypeActions,
assertUnreachable,
} from '../common/utils';
import type { ExternalReferenceAttachmentTypeRegistry } from '../attachment_framework/external_reference_registry';

export const decodeCommentRequest = (comment: CommentRequest) => {
export const decodeCommentRequest = (
comment: CommentRequest,
externalRefRegistry: ExternalReferenceAttachmentTypeRegistry
) => {
if (isCommentRequestTypeUser(comment)) {
pipe(excess(ContextTypeUserRt).decode(comment), fold(throwErrors(badRequest), identity));
} else if (isCommentRequestTypeActions(comment)) {
Expand Down Expand Up @@ -106,7 +110,7 @@ export const decodeCommentRequest = (comment: CommentRequest) => {
);
}
} else if (isCommentRequestTypeExternalReference(comment)) {
decodeExternalReferenceAttachment(comment);
decodeExternalReferenceAttachment(comment, externalRefRegistry);
} else if (isCommentRequestTypePersistableState(comment)) {
pipe(
excess(PersistableStateAttachmentRt).decode(comment),
Expand All @@ -122,7 +126,10 @@ export const decodeCommentRequest = (comment: CommentRequest) => {
}
};

const decodeExternalReferenceAttachment = (attachment: CommentRequestExternalReferenceType) => {
const decodeExternalReferenceAttachment = (
attachment: CommentRequestExternalReferenceType,
externalRefRegistry: ExternalReferenceAttachmentTypeRegistry
) => {
if (attachment.externalReferenceStorage.type === ExternalReferenceStorageType.savedObject) {
pipe(excess(ExternalReferenceSORt).decode(attachment), fold(throwErrors(badRequest), identity));
} else {
Expand All @@ -131,6 +138,13 @@ const decodeExternalReferenceAttachment = (attachment: CommentRequestExternalRef
fold(throwErrors(badRequest), identity)
);
}

const metadata = attachment.externalReferenceMetadata;
if (externalRefRegistry.has(attachment.externalReferenceAttachmentTypeId)) {
const attachmentType = externalRefRegistry.get(attachment.externalReferenceAttachmentTypeId);

attachmentType.schemaValidator?.(metadata);
}
};

/**
Expand Down
65 changes: 65 additions & 0 deletions x-pack/plugins/cases/server/common/limiter_checker/base_limiter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* 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 * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { KueryNode } from '@kbn/es-query';
import { CASE_COMMENT_SAVED_OBJECT } from '../../../common/constants';
import type { CommentRequest, CommentType } from '../../../common/api';
import type { AttachmentService } from '../../services';
import type { Limiter } from './types';

interface LimiterParams {
limit: number;
attachmentType: CommentType;
field: string;
attachmentNoun: string;
filter?: KueryNode;
}

export abstract class BaseLimiter implements Limiter {
public readonly limit: number;
public readonly errorMessage: string;

private readonly limitAggregation: Record<string, estypes.AggregationsAggregationContainer>;
private readonly params: LimiterParams;

constructor(params: LimiterParams) {
this.params = params;
this.limit = params.limit;
this.errorMessage = makeErrorMessage(this.limit, params.attachmentNoun);

this.limitAggregation = {
limiter: {
value_count: {
field: `${CASE_COMMENT_SAVED_OBJECT}.attributes.${params.field}`,
},
},
};
}

public async countOfItemsWithinCase(
attachmentService: AttachmentService,
caseId: string
): Promise<number> {
const itemsAttachedToCase = await attachmentService.executeCaseAggregations<{
limiter: { value: number };
}>({
caseId,
aggregations: this.limitAggregation,
attachmentType: this.params.attachmentType,
filter: this.params.filter,
});

return itemsAttachedToCase?.limiter?.value ?? 0;
}

public abstract countOfItemsInRequest(requests: CommentRequest[]): number;
}

const makeErrorMessage = (limit: number, noun: string) => {
return `Case has reached the maximum allowed number (${limit}) of attached ${noun}.`;
};
142 changes: 142 additions & 0 deletions x-pack/plugins/cases/server/common/limiter_checker/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/*
* 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 { AttachmentLimitChecker } from '.';
import { createAttachmentServiceMock } from '../../services/mocks';
import { createAlertRequests, createFileRequests, createUserRequests } from './test_utils';

describe('AttachmentLimitChecker', () => {
const mockAttachmentService = createAttachmentServiceMock();
const checker = new AttachmentLimitChecker(mockAttachmentService, 'id');

beforeEach(() => {
jest.clearAllMocks();

mockAttachmentService.executeCaseAggregations.mockImplementation(async () => {
return {
limiter: {
value: 5,
},
};
});
});

describe('validate', () => {
it('does not throw when called with an empty array', async () => {
expect.assertions(1);

await expect(checker.validate([])).resolves.not.toThrow();
});

it('does not throw when none of the requests are alerts or files', async () => {
expect.assertions(1);

await expect(checker.validate(createUserRequests(2))).resolves.not.toThrow();
});

describe('files', () => {
it('throws when the files in a single request are greater than 100', async () => {
expect.assertions(1);

await expect(
checker.validate(createFileRequests({ numRequests: 1, numFiles: 101 }))
).rejects.toMatchInlineSnapshot(
`[Error: Case has reached the maximum allowed number (100) of attached files.]`
);
});

it('throws when there are 101 requests with a single file', async () => {
expect.assertions(1);

await expect(
checker.validate(createFileRequests({ numRequests: 101, numFiles: 1 }))
).rejects.toMatchInlineSnapshot(
`[Error: Case has reached the maximum allowed number (100) of attached files.]`
);
});

it('does not throw when the sum of the file requests and files within the case are only 100', async () => {
expect.assertions(1);

await expect(
checker.validate(createFileRequests({ numFiles: 1, numRequests: 95 }))
).resolves.not.toThrow();
});

it('throws when the sum of the file requests and files within the case are 101', async () => {
expect.assertions(1);

await expect(
checker.validate(createFileRequests({ numFiles: 1, numRequests: 96 }))
).rejects.toMatchInlineSnapshot(
`[Error: Case has reached the maximum allowed number (100) of attached files.]`
);
});

it('throws when the sum of the file requests and files within the case are 101 with a single file request', async () => {
expect.assertions(1);

await expect(
checker.validate(createFileRequests({ numFiles: 96, numRequests: 1 }))
).rejects.toMatchInlineSnapshot(
`[Error: Case has reached the maximum allowed number (100) of attached files.]`
);
});
});

describe('alerts', () => {
it('throws when the alerts in a single request are greater than 1000', async () => {
expect.assertions(1);

const alertIds = [...Array(1001).keys()].map((key) => `${key}`);
await expect(
checker.validate(createAlertRequests(1, alertIds))
).rejects.toMatchInlineSnapshot(
`[Error: Case has reached the maximum allowed number (1000) of attached alerts.]`
);
});

it('throws when there are 1001 requests with a single alert id', async () => {
expect.assertions(1);

await expect(
checker.validate(createAlertRequests(1001, 'alertId'))
).rejects.toMatchInlineSnapshot(
`[Error: Case has reached the maximum allowed number (1000) of attached alerts.]`
);
});

it('does not throw when the sum of the alert requests and alerts within the case are only 1000', async () => {
expect.assertions(1);

await expect(checker.validate(createAlertRequests(995, 'alertId'))).resolves.not.toThrow();
});

it('throws when the sum of the alert requests and alerts within the case are 1001', async () => {
expect.assertions(1);

await expect(
checker.validate(createAlertRequests(996, 'alertId'))
).rejects.toMatchInlineSnapshot(
`[Error: Case has reached the maximum allowed number (1000) of attached alerts.]`
);
});

it('throws when the sum of the alert requests and alerts within the case are 1001 with a single request', async () => {
expect.assertions(1);

const alertIds = [...Array(996).keys()].map((key) => `${key}`);

await expect(
checker.validate(createAlertRequests(1, alertIds))
).rejects.toMatchInlineSnapshot(
`[Error: Case has reached the maximum allowed number (1000) of attached alerts.]`
);
});
});
});
});
Loading

0 comments on commit 8aaa687

Please sign in to comment.