Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Cases] Limit the number of alerts that can be attached to a case #129988

Merged
merged 7 commits into from
Apr 14, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { sortSchema } from './common_schemas';
* - max
* - sum
* - top_hits
* - value_count
* - weighted_avg
*
* Not implemented:
Expand Down Expand Up @@ -76,6 +77,9 @@ export const metricsAggsSchemas: Record<string, ObjectType> = {
highlight: s.maybe(s.any()),
_source: s.maybe(s.oneOf([s.boolean(), s.string(), s.arrayOf(s.string())])),
}),
value_count: s.object({
field: s.maybe(s.string()),
}),
weighted_avg: s.object({
format: s.maybe(s.string()),
value_type: s.maybe(s.string()),
Expand Down
8 changes: 7 additions & 1 deletion x-pack/plugins/cases/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,11 @@ export const SUPPORTED_CONNECTORS = [
/**
* Alerts
*/
export const MAX_ALERTS_PER_CASE = 5000 as const;
export const MAX_ALERTS_PER_CASE = 1000 as const;

/**
* Owner
*/
export const SECURITY_SOLUTION_OWNER = 'securitySolution' as const;
export const OBSERVABILITY_OWNER = 'observability' as const;

Expand All @@ -113,6 +116,9 @@ export const OWNER_INFO = {
},
} as const;

/**
* Searching
*/
export const MAX_DOCS_PER_PAGE = 10000 as const;
export const MAX_CONCURRENT_SEARCHES = 10 as const;

Expand Down
54 changes: 46 additions & 8 deletions x-pack/plugins/cases/server/common/models/case_with_comments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,13 @@ import {
CaseAttributes,
ActionTypes,
Actions,
CommentRequestAlertType,
} from '../../../common/api';
import { CASE_SAVED_OBJECT, MAX_DOCS_PER_PAGE } from '../../../common/constants';
import {
CASE_SAVED_OBJECT,
MAX_ALERTS_PER_CASE,
MAX_DOCS_PER_PAGE,
} from '../../../common/constants';
import { CasesClientArgs } from '../../client';
import { createCaseError } from '../error';
import {
Expand All @@ -34,9 +39,11 @@ import {
transformNewComment,
getOrUpdateLensReferences,
createAlertUpdateRequest,
isCommentRequestTypeAlert,
} from '../utils';

type CaseCommentModelParams = Omit<CasesClientArgs, 'authorization'>;
const ALERT_LIMIT_MSG = `Case has already reach the maximum allowed number (${MAX_ALERTS_PER_CASE}) of attached alerts on a case`;

/**
* This class represents a case that can have a comment attached to it.
Expand Down Expand Up @@ -188,7 +195,7 @@ export class CaseCommentModel {
id: string;
}): Promise<CaseCommentModel> {
try {
this.validateCreateCommentRequest([commentReq]);
await this.validateCreateCommentRequest([commentReq]);

const references = [...this.buildRefsToCase(), ...this.getCommentReferences(commentReq)];

Expand Down Expand Up @@ -221,17 +228,48 @@ export class CaseCommentModel {
}
}

private validateCreateCommentRequest(req: CommentRequest[]) {
if (
req.some((attachment) => attachment.type === CommentType.alert) &&
this.caseInfo.attributes.status === CaseStatuses.closed
) {
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;

if (reqHasAlerts && 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) {
academo marked this conversation as resolved.
Show resolved Hide resolved
throw Boom.badRequest(ALERT_LIMIT_MSG);
}

await this.validateAlertsLimitOnCase(totalAlertsInReq);
}
}

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

if (alertsValueCount + totalAlertsInReq > MAX_ALERTS_PER_CASE) {
academo marked this conversation as resolved.
Show resolved Hide resolved
throw Boom.badRequest(ALERT_LIMIT_MSG);
}
}

private buildRefsToCase(): SavedObjectReference[] {
Expand Down Expand Up @@ -354,7 +392,7 @@ export class CaseCommentModel {
attachments: Array<{ id: string } & CommentRequest>;
}): Promise<CaseCommentModel> {
try {
this.validateCreateCommentRequest(attachments);
await this.validateCreateCommentRequest(attachments);

const caseReference = this.buildRefsToCase();

Expand Down
103 changes: 60 additions & 43 deletions x-pack/plugins/cases/server/services/attachments/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,13 @@ interface AttachedToCaseArgs extends ClientArgs {
}

type GetAllAlertsAttachToCaseArgs = AttachedToCaseArgs;
type CountAlertsAttachedToCaseArgs = AttachedToCaseArgs;
type AlertsAttachedToCaseArgs = AttachedToCaseArgs;

interface AttachmentsAttachedToCaseArgs extends AttachedToCaseArgs {
attachmentType: CommentType;
aggregations: Record<string, estypes.AggregationsAggregationContainer>;
}

interface CountActionsAttachedToCaseArgs extends AttachedToCaseArgs {
aggregations: Record<string, estypes.AggregationsAggregationContainer>;
}
Expand Down Expand Up @@ -79,52 +85,50 @@ interface CommentStats {
export class AttachmentService {
constructor(private readonly log: Logger) {}

public async countAlertsAttachedToCase({
unsecuredSavedObjectsClient,
caseId,
filter,
}: CountAlertsAttachedToCaseArgs): Promise<number | undefined> {
public async countAlertsAttachedToCase(
params: AlertsAttachedToCaseArgs
): Promise<number | undefined> {
try {
this.log.debug(`Attempting to count 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 response = await unsecuredSavedObjectsClient.find<
AttachmentAttributes,
{ alerts: { value: number } }
>({
type: CASE_COMMENT_SAVED_OBJECT,
hasReference: { type: CASE_SAVED_OBJECT, id: caseId },
page: 1,
perPage: 1,
sortField: defaultSortField,
aggs: this.buildCountAlertsAggs(),
filter: combinedFilter,
this.log.debug(`Attempting to count alerts for case id ${params.caseId}`);
const res = await this.executeCaseAggregations<{ alerts: { value: number } }>({
...params,
attachmentType: CommentType.alert,
aggregations: this.buildAlertsAggs('cardinality'),
});

return response.aggregations?.alerts?.value;
return res?.alerts?.value;
} catch (error) {
this.log.error(`Error while counting alerts for case id ${caseId}: ${error}`);
this.log.error(`Error while counting alerts for case id ${params.caseId}: ${error}`);
throw error;
}
}

private buildCountAlertsAggs(): Record<string, estypes.AggregationsAggregationContainer> {
private buildAlertsAggs(agg: string): Record<string, estypes.AggregationsAggregationContainer> {
return {
alerts: {
cardinality: {
[agg]: {
field: `${CASE_COMMENT_SAVED_OBJECT}.attributes.alertId`,
},
},
};
}

public async valueCountAlertsAttachedToCase(params: AlertsAttachedToCaseArgs): Promise<number> {
try {
this.log.debug(`Attempting to value count alerts for case id ${params.caseId}`);
const res = await this.executeCaseAggregations<{ alerts: { value: number } }>({
...params,
attachmentType: CommentType.alert,
aggregations: this.buildAlertsAggs('value_count'),
});

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.
*/
Expand Down Expand Up @@ -166,29 +170,27 @@ export class AttachmentService {
}

/**
* Executes the aggregations against the actions attached to a case.
* Executes the aggregations against a type of attachment attached to a case.
*/
public async executeCaseActionsAggregations({
public async executeCaseAggregations<Agg extends AggregationResponse = AggregationResponse>({
unsecuredSavedObjectsClient,
caseId,
filter,
aggregations,
}: CountActionsAttachedToCaseArgs): Promise<AggregationResponse | undefined> {
attachmentType,
}: AttachmentsAttachedToCaseArgs): Promise<Agg | undefined> {
try {
this.log.debug(`Attempting to count actions for case id ${caseId}`);
const actionsFilter = buildFilter({
filters: [CommentType.actions],
this.log.debug(`Attempting to aggregate for case id ${caseId}`);
const attachmentFilter = buildFilter({
filters: attachmentType,
field: 'type',
operator: 'or',
type: CASE_COMMENT_SAVED_OBJECT,
});

const combinedFilter = combineFilters([actionsFilter, filter]);
const combinedFilter = combineFilters([attachmentFilter, filter]);

const response = await unsecuredSavedObjectsClient.find<
AttachmentAttributes,
AggregationResponse
>({
const response = await unsecuredSavedObjectsClient.find<AttachmentAttributes, Agg>({
type: CASE_COMMENT_SAVED_OBJECT,
hasReference: { type: CASE_SAVED_OBJECT, id: caseId },
page: 1,
Expand All @@ -200,7 +202,22 @@ export class AttachmentService {

return response.aggregations;
} catch (error) {
this.log.error(`Error while counting actions for case id ${caseId}: ${error}`);
this.log.error(`Error while executing aggregation for case id ${caseId}: ${error}`);
throw error;
}
}

/**
* Executes the aggregations against the actions attached to a case.
*/
public async executeCaseActionsAggregations(
params: CountActionsAttachedToCaseArgs
): Promise<AggregationResponse | undefined> {
try {
this.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}`);
throw error;
}
}
Expand Down
2 changes: 2 additions & 0 deletions x-pack/plugins/cases/server/services/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ export const createAttachmentServiceMock = (): AttachmentServiceMock => {
countAlertsAttachedToCase: jest.fn(),
executeCaseActionsAggregations: jest.fn(),
getCaseCommentStats: jest.fn(),
valueCountAlertsAttachedToCase: jest.fn(),
executeCaseAggregations: jest.fn(),
};

// the cast here is required because jest.Mocked tries to include private members and would throw an error
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,59 @@ export default ({ getService }: FtrProviderContext): void => {
expectedHttpCode: 400,
});
});

it('400s when attempting to add more than 1K alerts to a case', async () => {
const alerts = [...Array(1001).keys()].map((num) => `test-${num}`);
const postedCase = await createCase(supertest, postCaseReq);
await createComment({
supertest,
caseId: postedCase.id,
params: { ...postCommentAlertReq, alertId: alerts, index: alerts },
expectedHttpCode: 400,
});
});

it('400s when attempting to add an alert to a case that already has 1K alerts', async () => {
const alerts = [...Array(1000).keys()].map((num) => `test-${num}`);
const postedCase = await createCase(supertest, postCaseReq);
await createComment({
supertest,
caseId: postedCase.id,
params: { ...postCommentAlertReq, alertId: alerts, index: alerts },
});

await createComment({
supertest,
caseId: postedCase.id,
params: { ...postCommentAlertReq, alertId: 'test-id', index: 'test-index' },
expectedHttpCode: 400,
});
});

it('400s when the case already has alerts and the sum of existing and new alerts exceed 1k', async () => {
const alerts = [...Array(1200).keys()].map((num) => `test-${num}`);
const postedCase = await createCase(supertest, postCaseReq);
await createComment({
supertest,
caseId: postedCase.id,
params: {
...postCommentAlertReq,
alertId: alerts.slice(0, 500),
index: alerts.slice(0, 500),
},
});

await createComment({
supertest,
caseId: postedCase.id,
params: {
...postCommentAlertReq,
alertId: alerts.slice(500),
index: alerts.slice(500),
},
expectedHttpCode: 400,
});
});
});

describe('alerts', () => {
Expand Down
Loading