[Cases] Get all categories API (#159608)

## Summary

An API to return all categories available to a user.

This will be used for filtering and for the select box when setting the
category of a case.
This commit is contained in:
Antonio 2023-06-14 15:09:01 +02:00 committed by GitHub
parent 1a70ede796
commit d8185993f1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 457 additions and 4 deletions

View file

@ -383,9 +383,20 @@ export const AllTagsFindRequestRt = rt.exact(
})
);
export const AllCategoriesFindRequestRt = rt.exact(
rt.partial({
/**
* The owner of the cases to retrieve the categories from. If no owner is provided the categories
* from all cases that the user has access to will be returned.
*/
owner: rt.union([rt.array(rt.string), rt.string]),
})
);
export const AllReportersFindRequestRt = AllTagsFindRequestRt;
export const GetTagsResponseRt = rt.array(rt.string);
export const GetCategoriesResponseRt = rt.array(rt.string);
export const GetReportersResponseRt = rt.array(UserRt);
export const CasesBulkGetRequestRt = rt.strict({
@ -421,6 +432,7 @@ export type ExternalServiceResponse = rt.TypeOf<typeof ExternalServiceResponseRt
export type CaseExternalServiceBasic = rt.TypeOf<typeof CaseExternalServiceBasicRt>;
export type AllTagsFindRequest = rt.TypeOf<typeof AllTagsFindRequestRt>;
export type AllCategoriesFindRequest = rt.TypeOf<typeof AllCategoriesFindRequestRt>;
export type AllReportersFindRequest = AllTagsFindRequest;
export type AttachmentTotals = rt.TypeOf<typeof AttachmentTotalsRt>;

View file

@ -81,6 +81,7 @@ export const INTERNAL_GET_CASE_USER_ACTIONS_STATS_URL =
export const INTERNAL_CASE_USERS_URL = `${CASES_INTERNAL_URL}/{case_id}/_users` as const;
export const INTERNAL_DELETE_FILE_ATTACHMENTS_URL =
`${CASES_INTERNAL_URL}/{case_id}/attachments/files/_bulk_delete` as const;
export const INTERNAL_GET_CASE_CATEGORIES_URL = `${CASES_INTERNAL_URL}/categories` as const;
/**
* Action routes

View file

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

View file

@ -367,4 +367,12 @@ export const Operations: Record<ReadOperations | WriteOperations, OperationDetai
docType: 'user actions',
savedObjectType: CASE_USER_ACTION_SAVED_OBJECT,
},
[ReadOperations.GetCategories]: {
ecsType: EVENT_TYPES.access,
name: ACCESS_CASE_OPERATION,
action: 'case_categories_get',
verbs: accessVerbs,
docType: 'cases',
savedObjectType: CASE_SAVED_OBJECT,
},
};

View file

@ -37,6 +37,7 @@ export enum ReadOperations {
GetAllComments = 'getAllComments',
FindComments = 'findComments',
GetTags = 'getTags',
GetCategories = 'getCategories',
GetReporters = 'getReporters',
FindConfigurations = 'findConfigurations',
FindUserActions = 'findUserActions',

View file

@ -11,6 +11,7 @@ import type {
CasesFindRequest,
User,
AllTagsFindRequest,
AllCategoriesFindRequest,
AllReportersFindRequest,
CasesByAlertId,
CasesBulkGetRequest,
@ -33,7 +34,7 @@ import { create } from './create';
import { deleteCases } from './delete';
import { find } from './find';
import type { CasesByAlertIDParams, GetParams } from './get';
import { get, resolve, getCasesByAlertID, getReporters, getTags } from './get';
import { get, resolve, getCasesByAlertID, getReporters, getTags, getCategories } from './get';
import type { PushParams } from './push';
import { push } from './push';
import { update } from './update';
@ -83,6 +84,10 @@ export interface CasesSubClient {
* Retrieves all the tags across all cases the user making the request has access to.
*/
getTags(params: AllTagsFindRequest): Promise<string[]>;
/**
* Retrieves all the categories across all cases the user making the request has access to.
*/
getCategories(params: AllCategoriesFindRequest): Promise<string[]>;
/**
* Retrieves all the reporters across all accessible cases.
*/
@ -113,6 +118,7 @@ export const createCasesSubClient = (
update: (cases: CasesPatchRequest) => update(cases, clientArgs),
delete: (ids: string[]) => deleteCases(ids, clientArgs),
getTags: (params: AllTagsFindRequest) => getTags(params, clientArgs),
getCategories: (params: AllCategoriesFindRequest) => getCategories(params, clientArgs),
getReporters: (params: AllReportersFindRequest) => getReporters(params, clientArgs),
getCasesByAlertID: (params: CasesByAlertIDParams) => getCasesByAlertID(params, clientArgs),
};

View file

@ -6,7 +6,7 @@
*/
import { createCasesClientMockArgs } from '../mocks';
import { getCasesByAlertID, getTags, getReporters } from './get';
import { getCasesByAlertID, getTags, getReporters, getCategories } from './get';
describe('get', () => {
const clientArgs = createCasesClientMockArgs();
@ -44,4 +44,13 @@ describe('get', () => {
);
});
});
describe('getCategories', () => {
it('throws with excess fields', async () => {
// @ts-expect-error: excess attribute
await expect(getCategories({ owner: 'cases', foo: 'bar' }, clientArgs)).rejects.toThrow(
'invalid keys "foo"'
);
});
});
});

View file

@ -11,6 +11,7 @@ import type {
CaseResolveResponse,
User,
AllTagsFindRequest,
AllCategoriesFindRequest,
AllReportersFindRequest,
CasesByAlertIDRequest,
CasesByAlertId,
@ -18,15 +19,17 @@ import type {
AttachmentTotals,
} from '../../../common/api';
import {
AllTagsFindRequestRt,
AllCategoriesFindRequestRt,
CaseRt,
CaseResolveResponseRt,
AllTagsFindRequestRt,
decodeWithExcessOrThrow,
AllReportersFindRequestRt,
CasesByAlertIDRequestRt,
CasesByAlertIdRt,
GetTagsResponseRt,
GetReportersResponseRt,
GetCategoriesResponseRt,
} from '../../../common/api';
import { createCaseError } from '../../common/error';
import { countAlertsForID, flattenCaseSavedObject } from '../../common/utils';
@ -299,7 +302,7 @@ export async function getTags(
const queryParams = decodeWithExcessOrThrow(AllTagsFindRequestRt)(params);
const { filter: authorizationFilter } = await authorization.getAuthorizationFilter(
Operations.findCases
Operations.getTags
);
const filter = combineAuthorizedAndOwnerFilter(queryParams.owner, authorizationFilter);
@ -348,3 +351,37 @@ export async function getReporters(
throw createCaseError({ message: `Failed to get reporters: ${error}`, error, logger });
}
}
/**
* Retrieves the categories from all the cases.
*/
export async function getCategories(
params: AllCategoriesFindRequest,
clientArgs: CasesClientArgs
): Promise<string[]> {
const {
unsecuredSavedObjectsClient,
services: { caseService },
logger,
authorization,
} = clientArgs;
try {
const queryParams = decodeWithExcessOrThrow(AllCategoriesFindRequestRt)(params);
const { filter: authorizationFilter } = await authorization.getAuthorizationFilter(
Operations.getCategories
);
const filter = combineAuthorizedAndOwnerFilter(queryParams.owner, authorizationFilter);
const categories = await caseService.getCategories({
unsecuredSavedObjectsClient,
filter,
});
return decodeOrThrow(GetCategoriesResponseRt)(categories);
} catch (error) {
throw createCaseError({ message: `Failed to get categories: ${error}`, error, logger });
}
}

View file

@ -58,6 +58,7 @@ const createCasesSubClientMock = (): CasesSubClientMock => {
getTags: jest.fn(),
getReporters: jest.fn(),
getCasesByAlertID: jest.fn(),
getCategories: jest.fn(),
};
};

View file

@ -0,0 +1,31 @@
/*
* 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 { AllCategoriesFindRequest } from '../../../../../common/api';
import { INTERNAL_GET_CASE_CATEGORIES_URL } from '../../../../../common/constants';
import { createCaseError } from '../../../../common/error';
import { createCasesRoute } from '../../create_cases_route';
export const getCategoriesRoute = createCasesRoute({
method: 'get',
path: INTERNAL_GET_CASE_CATEGORIES_URL,
handler: async ({ context, request, response }) => {
try {
const caseContext = await context.cases;
const client = await caseContext.getCasesClient();
const options = request.query as AllCategoriesFindRequest;
return response.ok({ body: await client.cases.getCategories(options) });
} catch (error) {
throw createCaseError({
message: `Failed to retrieve categories in route: ${error}`,
error,
});
}
},
});

View file

@ -15,6 +15,7 @@ import type { CaseRoute } from './types';
import { bulkGetAttachmentsRoute } from './internal/bulk_get_attachments';
import { getCaseUsersRoute } from './internal/get_case_users';
import { bulkDeleteFileAttachments } from './internal/bulk_delete_file_attachments';
import { getCategoriesRoute } from './cases/categories/get_categories';
export const getInternalRoutes = (userProfileService: UserProfileService) =>
[
@ -26,4 +27,5 @@ export const getInternalRoutes = (userProfileService: UserProfileService) =>
bulkGetAttachmentsRoute,
getCaseUsersRoute,
bulkDeleteFileAttachments,
getCategoriesRoute,
] as CaseRoute[];

View file

@ -69,6 +69,7 @@ import type {
PostCaseArgs,
PatchCaseArgs,
PatchCasesArgs,
GetCategoryArgs,
} from './types';
import type { AttachmentTransformedAttributes } from '../../common/types/attachments';
import { bulkDecodeSOAttributes } from '../utils';
@ -557,6 +558,36 @@ export class CasesService {
}
}
public async getCategories({ filter }: GetCategoryArgs): Promise<string[]> {
try {
this.log.debug(`Attempting to GET all categories`);
const results = await this.unsecuredSavedObjectsClient.find<
unknown,
{ categories: { buckets: Array<{ key: string }> } }
>({
type: CASE_SAVED_OBJECT,
page: 1,
perPage: 1,
filter,
aggs: {
categories: {
terms: {
field: `${CASE_SAVED_OBJECT}.attributes.category`,
size: MAX_DOCS_PER_PAGE,
order: { _key: 'asc' },
},
},
},
});
return results?.aggregations?.categories?.buckets.map(({ key }) => key) ?? [];
} catch (error) {
this.log.error(`Error on GET categories: ${error}`);
throw error;
}
}
public async postNewCase({
attributes,
id,

View file

@ -81,6 +81,11 @@ export interface GetReportersArgs {
filter?: KueryNode;
}
export interface GetCategoryArgs {
unsecuredSavedObjectsClient: SavedObjectsClientContract;
filter?: KueryNode;
}
export interface GetCaseIdsByAlertIdAggs {
references: {
doc_count: number;

View file

@ -62,6 +62,7 @@ export const createCaseServiceMock = (): CaseServiceMock => {
getCaseStatusStats: jest.fn(),
executeAggregations: jest.fn(),
bulkDeleteCaseEntities: jest.fn(),
getCategories: jest.fn(),
};
// the cast here is required because jest.Mocked tries to include private members and would throw an error

View file

@ -24,6 +24,7 @@ import {
CASE_STATUS_URL,
CASE_TAGS_URL,
CASE_USER_ACTION_SAVED_OBJECT,
INTERNAL_GET_CASE_CATEGORIES_URL,
} from '@kbn/cases-plugin/common/constants';
import {
Configuration,
@ -660,6 +661,27 @@ export const getReporters = async ({
return res;
};
export const getCategories = async ({
supertest,
query = {},
expectedHttpCode = 200,
auth = { user: superUser, space: null },
}: {
supertest: SuperTest.SuperTest<SuperTest.Test>;
query?: Record<string, unknown>;
expectedHttpCode?: number;
auth?: { user: User; space: string | null };
}): Promise<CasesFindResponse> => {
const { body: res } = await supertest
.get(`${getSpaceUrlPrefix(auth.space)}${INTERNAL_GET_CASE_CATEGORIES_URL}`)
.auth(auth.user.username, auth.user.password)
.set('kbn-xsrf', 'true')
.query({ ...query })
.expect(expectedHttpCode);
return res;
};
export const pushCase = async ({
supertest,
caseId,

View file

@ -0,0 +1,201 @@
/*
* 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 { FtrProviderContext } from '../../../../../common/ftr_provider_context';
import { deleteCasesByESQuery, createCase, getCategories } from '../../../../../common/lib/api';
import { getPostCaseRequest } from '../../../../../common/lib/mock';
import {
secOnly,
obsOnly,
globalRead,
superUser,
secOnlyRead,
obsOnlyRead,
obsSecRead,
noKibanaPrivileges,
obsSec,
} from '../../../../../common/lib/authentication/users';
// eslint-disable-next-line import/no-default-export
export default ({ getService }: FtrProviderContext): void => {
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const es = getService('es');
describe('get_categories', () => {
afterEach(async () => {
await deleteCasesByESQuery(es);
});
it('should return case categories', async () => {
await createCase(supertest, getPostCaseRequest({ category: 'foo' }));
await createCase(supertest, getPostCaseRequest({ category: 'bar' }));
const categories = await getCategories({ supertest });
expect(categories).to.eql(['bar', 'foo']);
});
it('should return unique categories', async () => {
await createCase(supertest, getPostCaseRequest({ category: 'foobar' }));
await createCase(supertest, getPostCaseRequest({ category: 'foobar' }));
const categories = await getCategories({ supertest });
expect(categories).to.eql(['foobar']);
});
describe('rbac', () => {
it('should read the correct categories', async () => {
await createCase(
supertestWithoutAuth,
getPostCaseRequest({ owner: 'securitySolutionFixture', category: 'sec' }),
200,
{
user: secOnly,
space: 'space1',
}
);
await createCase(
supertestWithoutAuth,
getPostCaseRequest({ owner: 'observabilityFixture', category: 'obs' }),
200,
{
user: obsOnly,
space: 'space1',
}
);
for (const scenario of [
{
user: globalRead,
expectedCategories: ['obs', 'sec'],
},
{
user: superUser,
expectedCategories: ['obs', 'sec'],
},
{ user: secOnlyRead, expectedCategories: ['sec'] },
{ user: obsOnlyRead, expectedCategories: ['obs'] },
{
user: obsSecRead,
expectedCategories: ['obs', 'sec'],
},
]) {
const categories = await getCategories({
supertest: supertestWithoutAuth,
expectedHttpCode: 200,
auth: {
user: scenario.user,
space: 'space1',
},
});
expect(categories).to.eql(scenario.expectedCategories);
}
});
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 get all categories`, async () => {
// super user creates a case at the appropriate space
await createCase(
supertestWithoutAuth,
getPostCaseRequest({ owner: 'securitySolutionFixture', category: 'sec' }),
200,
{
user: superUser,
space: scenario.space,
}
);
// user should not be able to get all categories at the appropriate space
await getCategories({
supertest: supertestWithoutAuth,
expectedHttpCode: 403,
auth: { user: scenario.user, space: scenario.space },
});
});
}
it('should respect the owner filter when having permissions', async () => {
await Promise.all([
createCase(
supertestWithoutAuth,
getPostCaseRequest({ owner: 'securitySolutionFixture', category: 'sec' }),
200,
{
user: obsSec,
space: 'space1',
}
),
createCase(
supertestWithoutAuth,
getPostCaseRequest({ owner: 'observabilityFixture', category: 'obs' }),
200,
{
user: obsSec,
space: 'space1',
}
),
]);
const categories = await getCategories({
supertest: supertestWithoutAuth,
auth: {
user: obsSec,
space: 'space1',
},
query: { owner: 'securitySolutionFixture' },
});
expect(categories).to.eql(['sec']);
});
it('should return the correct cases when trying to exploit RBAC through the owner query parameter', async () => {
await Promise.all([
createCase(
supertestWithoutAuth,
getPostCaseRequest({ owner: 'securitySolutionFixture', category: 'sec' }),
200,
{
user: obsSec,
space: 'space1',
}
),
createCase(
supertestWithoutAuth,
getPostCaseRequest({ owner: 'observabilityFixture', category: 'obs' }),
200,
{
user: obsSec,
space: 'space1',
}
),
]);
// User with permissions only to security solution request categories from observability
const categories = await getCategories({
supertest: supertestWithoutAuth,
auth: {
user: secOnly,
space: 'space1',
},
query: { owner: ['securitySolutionFixture', 'observabilityFixture'] },
});
// Only security solution categories are being returned
expect(categories).to.eql(['sec']);
});
});
});
};

View file

@ -30,6 +30,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => {
loadTestFile(require.resolve('./cases/reporters/get_reporters'));
loadTestFile(require.resolve('./cases/status/get_status'));
loadTestFile(require.resolve('./cases/tags/get_tags'));
loadTestFile(require.resolve('./cases/categories/get_categories'));
loadTestFile(require.resolve('./user_actions/get_all_user_actions'));
loadTestFile(require.resolve('./user_actions/find_user_actions'));
loadTestFile(require.resolve('./user_actions/get_user_action_stats'));