Skip to content

Commit

Permalink
Registering files attachment type and limit logic
Browse files Browse the repository at this point in the history
  • Loading branch information
jonathan-buttner committed Feb 28, 2023
1 parent 3b15d79 commit cbb58f4
Show file tree
Hide file tree
Showing 13 changed files with 401 additions and 84 deletions.
5 changes: 5 additions & 0 deletions x-pack/plugins/cases/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,3 +206,8 @@ export const LOCAL_STORAGE_KEYS = {
casesQueryParams: 'cases.list.queryParams',
casesFilterOptions: 'cases.list.filterOptions',
};

/**
* Files
*/
export const MAX_FILES_PER_CASE = 100;
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}.`;
};
51 changes: 51 additions & 0 deletions x-pack/plugins/cases/server/common/limiter_checker/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import Boom from '@hapi/boom';

import type { CommentRequest } from '../../../common/api';
import type { AttachmentService } from '../../services';
import type { Limiter } from './types';
import { AlertLimiter } from './limiters/alerts';
import { FileLimiter } from './limiters/files';

export class LimitChecker {
private readonly limiters: Limiter[] = [new AlertLimiter(), new FileLimiter()];

constructor(
private readonly attachmentService: AttachmentService,
private readonly caseId: string
) {}

public async validate(requests: CommentRequest[]) {
for (const limiter of this.limiters) {
const itemsWithinRequests = limiter.countOfItemsInRequest(requests);
const hasItemsInRequests = itemsWithinRequests > 0;

const totalAfterRequests = async () => {
const itemsWithinCase = await limiter.countOfItemsWithinCase(
this.attachmentService,
this.caseId
);

return itemsWithinRequests + itemsWithinCase;
};

/**
* The call to totalAfterRequests is intentionally performed after checking the limit. If the number in the
* requests is greater than the max then we can skip checking how many items exist within the case because it is
* guaranteed to exceed.
*/
if (
hasItemsInRequests &&
(itemsWithinRequests > limiter.limit || (await totalAfterRequests()) > limiter.limit)
) {
throw Boom.badRequest(limiter.errorMessage);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* 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 { CommentType } from '../../../../common/api';
import type { CommentRequest, CommentRequestAlertType } from '../../../../common/api';
import { MAX_ALERTS_PER_CASE } from '../../../../common/constants';
import { isCommentRequestTypeAlert } from '../../utils';
import { BaseLimiter } from '../base_limiter';

export class AlertLimiter extends BaseLimiter {
constructor() {
super({
limit: MAX_ALERTS_PER_CASE,
attachmentType: CommentType.alert,
attachmentNoun: 'alerts',
field: 'alertId',
});
}

public countOfItemsInRequest(requests: CommentRequest[]): number {
const totalAlertsInReq = requests
.filter<CommentRequestAlertType>(isCommentRequestTypeAlert)
.reduce((count, attachment) => {
const ids = Array.isArray(attachment.alertId) ? attachment.alertId : [attachment.alertId];
return count + ids.length;
}, 0);

return totalAlertsInReq;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { buildFilter } from '../../../client/utils';
import { CommentType, FILE_ATTACHMENT_TYPE } from '../../../../common/api';
import type { CommentRequest } from '../../../../common/api';
import { CASE_COMMENT_SAVED_OBJECT, MAX_FILES_PER_CASE } from '../../../../common/constants';
import { isFileAttachmentRequest } from '../../utils';
import { BaseLimiter } from '../base_limiter';

export class FileLimiter extends BaseLimiter {
constructor() {
super({
limit: MAX_FILES_PER_CASE,
attachmentType: CommentType.externalReference,
field: 'externalReferenceAttachmentTypeId',
filter: createFileFilter(),
attachmentNoun: 'files',
});
}

public countOfItemsInRequest(requests: CommentRequest[]): number {
let fileRequests = 0;

for (const request of requests) {
if (isFileAttachmentRequest(request)) {
fileRequests++;
}
}

return fileRequests;
}
}

const createFileFilter = () =>
buildFilter({
filters: FILE_ATTACHMENT_TYPE,
field: 'externalReferenceAttachmentTypeId',
operator: 'or',
type: CASE_COMMENT_SAVED_OBJECT,
});
16 changes: 16 additions & 0 deletions x-pack/plugins/cases/server/common/limiter_checker/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/*
* 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 { CommentRequest } from '../../../common/api';
import type { AttachmentService } from '../../services';

export interface Limiter {
readonly limit: number;
readonly errorMessage: string;
countOfItemsWithinCase(attachmentService: AttachmentService, caseId: string): Promise<number>;
countOfItemsInRequest: (requests: CommentRequest[]) => number;
}
45 changes: 6 additions & 39 deletions x-pack/plugins/cases/server/common/models/case_with_comments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,11 @@ import {
ActionTypes,
Actions,
} from '../../../common/api';
import {
CASE_SAVED_OBJECT,
MAX_ALERTS_PER_CASE,
MAX_DOCS_PER_PAGE,
} from '../../../common/constants';
import { CASE_SAVED_OBJECT, MAX_DOCS_PER_PAGE } from '../../../common/constants';
import type { CasesClientArgs } from '../../client';
import type { RefreshSetting } from '../../services/types';
import { createCaseError } from '../error';
import { LimitChecker } from '../limiter_checker';
import type { AlertInfo, CaseSavedObject } from '../types';
import {
countAlertsForID,
Expand All @@ -47,8 +44,6 @@ import {

type CaseCommentModelParams = Omit<CasesClientArgs, 'authorization'>;

const ALERT_LIMIT_MSG = `Case has reached the maximum allowed number (${MAX_ALERTS_PER_CASE}) of attached alerts.`;

/**
* This class represents a case that can have a comment attached to it.
*/
Expand Down Expand Up @@ -239,47 +234,19 @@ export class CaseCommentModel {
}

private async validateCreateCommentRequest(req: CommentRequest[]) {
const totalAlertsInReq = req
.filter<CommentRequestAlertType>(isCommentRequestTypeAlert)
.reduce((count, attachment) => {
const ids = Array.isArray(attachment.alertId) ? attachment.alertId : [attachment.alertId];
return count + ids.length;
}, 0);

const reqHasAlerts = totalAlertsInReq > 0;
const hasAlertsInRequest = req.some((request) => isCommentRequestTypeAlert(request));

if (reqHasAlerts && this.caseInfo.attributes.status === CaseStatuses.closed) {
if (hasAlertsInRequest && this.caseInfo.attributes.status === CaseStatuses.closed) {
throw Boom.badRequest('Alert cannot be attached to a closed case');
}

if (req.some((attachment) => attachment.owner !== this.caseInfo.attributes.owner)) {
throw Boom.badRequest('The owner field of the comment must match the case');
}

if (reqHasAlerts) {
/**
* This check is for optimization reasons.
* It saves one aggregation if the total number
* of alerts of the request is already greater than
* MAX_ALERTS_PER_CASE
*/
if (totalAlertsInReq > MAX_ALERTS_PER_CASE) {
throw Boom.badRequest(ALERT_LIMIT_MSG);
}
const limitChecker = new LimitChecker(this.params.services.attachmentService, this.caseInfo.id);

await this.validateAlertsLimitOnCase(totalAlertsInReq);
}
}

private async validateAlertsLimitOnCase(totalAlertsInReq: number) {
const alertsValueCount =
await this.params.services.attachmentService.valueCountAlertsAttachedToCase({
caseId: this.caseInfo.id,
});

if (alertsValueCount + totalAlertsInReq > MAX_ALERTS_PER_CASE) {
throw Boom.badRequest(ALERT_LIMIT_MSG);
}
await limitChecker.validate(req);
}

private buildRefsToCase(): SavedObjectReference[] {
Expand Down
14 changes: 13 additions & 1 deletion x-pack/plugins/cases/server/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@

import type { SavedObject } from '@kbn/core-saved-objects-server';
import type { KueryNode } from '@kbn/es-query';
import type { CaseAttributes, SavedObjectFindOptions } from '../../common/api';
import type {
CaseAttributes,
CommentRequestExternalReferenceSOType,
FileAttachmentMetadata,
SavedObjectFindOptions,
} from '../../common/api';

/**
* This structure holds the alert ID and index from an alert comment
Expand All @@ -22,3 +27,10 @@ export type SavedObjectFindOptionsKueryNode = Omit<SavedObjectFindOptions, 'filt
};

export type CaseSavedObject = SavedObject<CaseAttributes>;

export type FileAttachmentRequest = Omit<
CommentRequestExternalReferenceSOType,
'externalReferenceMetadata'
> & {
externalReferenceMetadata: FileAttachmentMetadata;
};
16 changes: 15 additions & 1 deletion x-pack/plugins/cases/server/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
OWNER_INFO,
} from '../../common/constants';
import type { CASE_VIEW_PAGE_TABS } from '../../common/types';
import type { AlertInfo, CaseSavedObject } from './types';
import type { AlertInfo, CaseSavedObject, FileAttachmentRequest } from './types';

import type {
CaseAttributes,
Expand All @@ -47,6 +47,8 @@ import {
CommentType,
ConnectorTypes,
ExternalReferenceStorageType,
ExternalReferenceSORt,
FileAttachmentMetadataRt,
} from '../../common/api';
import type { UpdateAlertStatusRequest } from '../client/alerts/types';
import {
Expand Down Expand Up @@ -264,6 +266,18 @@ export const isCommentRequestTypeExternalReferenceSO = (
);
};

/**
* A type narrowing function for file attachments.
*/
export const isFileAttachmentRequest = (
context: Partial<CommentRequest>
): context is FileAttachmentRequest => {
return (
ExternalReferenceSORt.is(context) &&
FileAttachmentMetadataRt.is(context.externalReferenceMetadata)
);
};

/**
* Adds the ids and indices to a map of statuses
*/
Expand Down
Loading

0 comments on commit cbb58f4

Please sign in to comment.