mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[Cases] Create Bulk get cases internal API (#147674)
## Summary This PR creates the bulk get cases internal API. The endpoint is needed for the alerts table to be able to get all cases the alerts are attached to with one call. Reference: https://github.com/elastic/kibana/issues/146864 ### Request - ids: (Required, array) An array of IDs of the retrieved cases. - fields: (Optional, array) The fields to return in the attributes key of the object response. ``` POST <kibana host>:<port>/internal/cases/_bulk_get { "ids": ["case-id-1", "case-id-2", "123", "not-authorized"], "fields": ["title"] } ``` ### Response ``` { "cases": [ { "title": "case1", "owner": "securitySolution", "id": "case-id-1", "version": "WzIzMTU0NSwxNV0=" }, { "title": "case2", "owner": "observability", "id": "case-id-2", "version": "WzIzMTU0NSwxNV0=" } ], "errors": [ { "error": "Not Found", "message": "Saved object [cases/123] not found", "status": 404, "caseId": "123" }, { "error": "Forbidden", "message": "Unauthorized to access case with owner: \"cases\"", "status": 403, "caseId": "not-authorized" } ] } ``` ### Checklist Delete any items that are not applicable to this PR. - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios ### For maintainers - [x] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
This commit is contained in:
parent
4522e04287
commit
a8902e1b6e
23 changed files with 1279 additions and 18 deletions
|
@ -336,6 +336,10 @@ Refer to the corresponding {es} logs for potential write errors.
|
|||
| `success` | User has accessed a case.
|
||||
| `failure` | User is not authorized to access a case.
|
||||
|
||||
.2+| `case_bulk_get`
|
||||
| `success` | User has accessed multiple cases.
|
||||
| `failure` | User is not authorized to access multiple cases.
|
||||
|
||||
.2+| `case_resolve`
|
||||
| `success` | User has accessed a case.
|
||||
| `failure` | User is not authorized to access a case.
|
||||
|
|
|
@ -325,6 +325,27 @@ export const AllTagsFindRequestRt = rt.partial({
|
|||
|
||||
export const AllReportersFindRequestRt = AllTagsFindRequestRt;
|
||||
|
||||
export const CasesBulkGetRequestRt = rt.intersection([
|
||||
rt.type({
|
||||
ids: rt.array(rt.string),
|
||||
}),
|
||||
rt.partial({
|
||||
fields: rt.union([rt.undefined, rt.array(rt.string), rt.string]),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const CasesBulkGetResponseRt = rt.type({
|
||||
cases: CasesResponseRt,
|
||||
errors: rt.array(
|
||||
rt.type({
|
||||
error: rt.string,
|
||||
message: rt.string,
|
||||
status: rt.union([rt.undefined, rt.number]),
|
||||
caseId: rt.string,
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export type CaseAttributes = rt.TypeOf<typeof CaseAttributesRt>;
|
||||
|
||||
export type CasePostRequest = rt.TypeOf<typeof CasePostRequestRt>;
|
||||
|
@ -347,3 +368,16 @@ export type AllReportersFindRequest = AllTagsFindRequest;
|
|||
export type AttachmentTotals = rt.TypeOf<typeof AttachmentTotalsRt>;
|
||||
export type RelatedCaseInfo = rt.TypeOf<typeof RelatedCaseInfoRt>;
|
||||
export type CasesByAlertId = rt.TypeOf<typeof CasesByAlertIdRt>;
|
||||
|
||||
export type CasesBulkGetRequest = rt.TypeOf<typeof CasesBulkGetRequestRt>;
|
||||
export type CasesBulkGetResponse = rt.TypeOf<typeof CasesBulkGetResponseRt>;
|
||||
export type CasesBulkGetRequestCertainFields<
|
||||
Field extends keyof CaseResponse = keyof CaseResponse
|
||||
> = Omit<CasesBulkGetRequest, 'fields'> & {
|
||||
fields?: Field[];
|
||||
};
|
||||
export type CasesBulkGetResponseCertainFields<
|
||||
Field extends keyof CaseResponse = keyof CaseResponse
|
||||
> = Omit<CasesBulkGetResponse, 'cases'> & {
|
||||
cases: Array<Pick<CaseResponse, Field | 'id' | 'version' | 'owner'>>;
|
||||
};
|
||||
|
|
|
@ -35,7 +35,7 @@ export const decodeOrThrow =
|
|||
(inputValue: I) =>
|
||||
pipe(runtimeType.decode(inputValue), fold(throwErrors(createError), identity));
|
||||
|
||||
const getProps = (
|
||||
export const getTypeProps = (
|
||||
codec:
|
||||
| rt.HasProps
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
@ -51,10 +51,10 @@ const getProps = (
|
|||
return codec.codomain.props;
|
||||
}
|
||||
const dTypes: rt.HasProps[] = codec.codomain.types;
|
||||
return dTypes.reduce<rt.Props>((props, type) => Object.assign(props, getProps(type)), {});
|
||||
return dTypes.reduce<rt.Props>((props, type) => Object.assign(props, getTypeProps(type)), {});
|
||||
case 'RefinementType':
|
||||
case 'ReadonlyType':
|
||||
return getProps(codec.type);
|
||||
return getTypeProps(codec.type);
|
||||
case 'InterfaceType':
|
||||
case 'StrictType':
|
||||
case 'PartialType':
|
||||
|
@ -62,7 +62,7 @@ const getProps = (
|
|||
case 'IntersectionType':
|
||||
const iTypes = codec.types as rt.HasProps[];
|
||||
return iTypes.reduce<rt.Props>((props, type) => {
|
||||
return Object.assign(props, getProps(type) as rt.Props);
|
||||
return Object.assign(props, getTypeProps(type) as rt.Props);
|
||||
}, {} as rt.Props) as rt.Props;
|
||||
default:
|
||||
return null;
|
||||
|
@ -82,7 +82,7 @@ const getExcessProps = (props: rt.Props, r: Record<string, unknown>): string[] =
|
|||
export function excess<
|
||||
C extends rt.InterfaceType<rt.Props> | GenericIntersectionC | rt.PartialType<rt.Props>
|
||||
>(codec: C): C {
|
||||
const codecProps = getProps(codec);
|
||||
const codecProps = getTypeProps(codec);
|
||||
|
||||
const r = new rt.InterfaceType(
|
||||
codec.name,
|
||||
|
@ -123,3 +123,33 @@ export const jsonArrayRt: rt.Type<JsonArray> = rt.recursion('JsonArray', () =>
|
|||
export const jsonObjectRt: rt.Type<JsonObject> = rt.recursion('JsonObject', () =>
|
||||
rt.record(rt.string, jsonValueRt)
|
||||
);
|
||||
|
||||
type Type = rt.InterfaceType<rt.Props> | GenericIntersectionC;
|
||||
|
||||
export const getTypeForCertainFields = (type: Type, fields: string[] = []): Type => {
|
||||
if (fields.length === 0) {
|
||||
return type;
|
||||
}
|
||||
|
||||
const codecProps = getTypeProps(type) ?? {};
|
||||
const typeProps: rt.Props = {};
|
||||
|
||||
for (const field of fields) {
|
||||
if (codecProps[field]) {
|
||||
typeProps[field] = codecProps[field];
|
||||
}
|
||||
}
|
||||
|
||||
return rt.type(typeProps);
|
||||
};
|
||||
|
||||
export const getTypeForCertainFieldsFromArray = (
|
||||
type: rt.ArrayType<Type>,
|
||||
fields: string[] = []
|
||||
): rt.ArrayType<Type> => {
|
||||
if (fields.length === 0) {
|
||||
return type;
|
||||
}
|
||||
|
||||
return rt.array(getTypeForCertainFields(type.type, fields));
|
||||
};
|
||||
|
|
|
@ -89,6 +89,7 @@ export const INTERNAL_BULK_CREATE_ATTACHMENTS_URL =
|
|||
`${CASES_INTERNAL_URL}/{case_id}/attachments/_bulk_create` as const;
|
||||
export const INTERNAL_SUGGEST_USER_PROFILES_URL =
|
||||
`${CASES_INTERNAL_URL}/_suggest_user_profiles` as const;
|
||||
export const INTERNAL_BULK_GET_CASES_URL = `${CASES_INTERNAL_URL}/_bulk_get` as const;
|
||||
|
||||
/**
|
||||
* Action routes
|
||||
|
@ -136,6 +137,7 @@ export const OWNER_INFO = {
|
|||
*/
|
||||
export const MAX_DOCS_PER_PAGE = 10000 as const;
|
||||
export const MAX_CONCURRENT_SEARCHES = 10 as const;
|
||||
export const MAX_BULK_GET_CASES = 1000 as const;
|
||||
|
||||
/**
|
||||
* Validation
|
||||
|
|
|
@ -1,5 +1,89 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`audit_logger log function event structure creates the correct audit event for operation: "bulkGetCases" with an error and entity 1`] = `
|
||||
Object {
|
||||
"error": Object {
|
||||
"code": "Error",
|
||||
"message": "an error",
|
||||
},
|
||||
"event": Object {
|
||||
"action": "case_bulk_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: "bulkGetCases" with an error but no entity 1`] = `
|
||||
Object {
|
||||
"error": Object {
|
||||
"code": "Error",
|
||||
"message": "an error",
|
||||
},
|
||||
"event": Object {
|
||||
"action": "case_bulk_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: "bulkGetCases" without an error but with an entity 1`] = `
|
||||
Object {
|
||||
"event": Object {
|
||||
"action": "case_bulk_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: "bulkGetCases" without an error or entity 1`] = `
|
||||
Object {
|
||||
"event": Object {
|
||||
"action": "case_bulk_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: "createCase" with an error and entity 1`] = `
|
||||
Object {
|
||||
"error": Object {
|
||||
|
|
|
@ -983,4 +983,338 @@ describe('authorization', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAndEnsureAuthorizedEntities', () => {
|
||||
const feature = { id: '1', cases: ['a', 'b'] };
|
||||
|
||||
let securityStart: ReturnType<typeof securityMock.createStart>;
|
||||
let featuresStart: jest.Mocked<FeaturesPluginStart>;
|
||||
let spacesStart: jest.Mocked<SpacesPluginStart>;
|
||||
let auth: Authorization;
|
||||
|
||||
beforeEach(async () => {
|
||||
securityStart = securityMock.createStart();
|
||||
securityStart.authz.mode.useRbacForRequest.mockReturnValue(true);
|
||||
securityStart.authz.checkPrivilegesDynamicallyWithRequest.mockReturnValue(
|
||||
jest.fn(async () => ({
|
||||
hasAllRequested: true,
|
||||
username: 'super',
|
||||
privileges: { kibana: [] },
|
||||
}))
|
||||
);
|
||||
|
||||
featuresStart = featuresPluginMock.createStart();
|
||||
featuresStart.getKibanaFeatures.mockReturnValue([feature] as unknown as KibanaFeature[]);
|
||||
|
||||
spacesStart = createSpacesDisabledFeaturesMock();
|
||||
|
||||
auth = await Authorization.create({
|
||||
request,
|
||||
securityAuth: securityStart.authz,
|
||||
spaces: spacesStart,
|
||||
features: featuresStart,
|
||||
auditLogger: new AuthorizationAuditLogger(mockLogger),
|
||||
logger: loggingSystemMock.createLogger(),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws and logs an error when there are no registered owners from plugins and security is enabled', async () => {
|
||||
expect.assertions(2);
|
||||
|
||||
featuresStart.getKibanaFeatures.mockReturnValue([]);
|
||||
|
||||
auth = await Authorization.create({
|
||||
request,
|
||||
securityAuth: securityStart.authz,
|
||||
spaces: spacesStart,
|
||||
features: featuresStart,
|
||||
auditLogger: new AuthorizationAuditLogger(mockLogger),
|
||||
logger: loggingSystemMock.createLogger(),
|
||||
});
|
||||
|
||||
try {
|
||||
await auth.getAndEnsureAuthorizedEntities({
|
||||
savedObjects: [
|
||||
{ id: '1', attributes: { owner: 'b' }, type: 'test', references: [] },
|
||||
{ id: '2', attributes: { owner: 'c' }, type: 'test', references: [] },
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
expect(error.message).toBe('Unauthorized to access cases of any owner');
|
||||
}
|
||||
|
||||
expect(mockLogger.log.mock.calls).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Array [
|
||||
Object {
|
||||
"error": Object {
|
||||
"code": "Error",
|
||||
"message": "Unauthorized to access cases of any owner",
|
||||
},
|
||||
"event": Object {
|
||||
"action": "case_bulk_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 \\"b\\"",
|
||||
},
|
||||
],
|
||||
Array [
|
||||
Object {
|
||||
"error": Object {
|
||||
"code": "Error",
|
||||
"message": "Unauthorized to access cases of any owner",
|
||||
},
|
||||
"event": Object {
|
||||
"action": "case_bulk_get",
|
||||
"category": Array [
|
||||
"database",
|
||||
],
|
||||
"outcome": "failure",
|
||||
"type": Array [
|
||||
"access",
|
||||
],
|
||||
},
|
||||
"kibana": Object {
|
||||
"saved_object": Object {
|
||||
"id": "2",
|
||||
"type": "cases",
|
||||
},
|
||||
},
|
||||
"message": "Failed attempt to access cases [id=2] as owner \\"c\\"",
|
||||
},
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('does not throw an error when a feature owner exists and security is disabled but logs', async () => {
|
||||
expect.assertions(2);
|
||||
|
||||
auth = await Authorization.create({
|
||||
request,
|
||||
spaces: spacesStart,
|
||||
features: featuresStart,
|
||||
auditLogger: new AuthorizationAuditLogger(mockLogger),
|
||||
logger: loggingSystemMock.createLogger(),
|
||||
});
|
||||
|
||||
const helpersPromise = auth.getAndEnsureAuthorizedEntities({
|
||||
savedObjects: [
|
||||
{ id: '1', attributes: { owner: 'a' }, type: 'test', references: [] },
|
||||
{ id: '2', attributes: { owner: 'b' }, type: 'test', references: [] },
|
||||
],
|
||||
});
|
||||
|
||||
await expect(helpersPromise).resolves.not.toThrow();
|
||||
|
||||
expect(mockLogger.log.mock.calls).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Array [
|
||||
Object {
|
||||
"event": Object {
|
||||
"action": "case_bulk_get",
|
||||
"category": Array [
|
||||
"database",
|
||||
],
|
||||
"outcome": "success",
|
||||
"type": Array [
|
||||
"access",
|
||||
],
|
||||
},
|
||||
"kibana": Object {
|
||||
"saved_object": Object {
|
||||
"id": "1",
|
||||
"type": "cases",
|
||||
},
|
||||
},
|
||||
"message": "User has accessed cases [id=1] as owner \\"a\\"",
|
||||
},
|
||||
],
|
||||
Array [
|
||||
Object {
|
||||
"event": Object {
|
||||
"action": "case_bulk_get",
|
||||
"category": Array [
|
||||
"database",
|
||||
],
|
||||
"outcome": "success",
|
||||
"type": Array [
|
||||
"access",
|
||||
],
|
||||
},
|
||||
"kibana": Object {
|
||||
"saved_object": Object {
|
||||
"id": "2",
|
||||
"type": "cases",
|
||||
},
|
||||
},
|
||||
"message": "User has accessed cases [id=2] as owner \\"b\\"",
|
||||
},
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
describe('hasAllRequested: true', () => {
|
||||
it('categorizes the registered owners a and b as authorized and the unregistered owner c as unauthorized', async () => {
|
||||
auth = await Authorization.create({
|
||||
request,
|
||||
spaces: spacesStart,
|
||||
features: featuresStart,
|
||||
auditLogger: new AuthorizationAuditLogger(mockLogger),
|
||||
logger: loggingSystemMock.createLogger(),
|
||||
});
|
||||
|
||||
const res = await auth.getAndEnsureAuthorizedEntities({
|
||||
savedObjects: [
|
||||
{ id: '1', attributes: { owner: 'a' }, type: 'test', references: [] },
|
||||
{ id: '2', attributes: { owner: 'b' }, type: 'test', references: [] },
|
||||
{ id: '3', attributes: { owner: 'c' }, type: 'test', references: [] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(res).toEqual({
|
||||
authorized: [
|
||||
{ id: '1', attributes: { owner: 'a' }, type: 'test', references: [] },
|
||||
{ id: '2', attributes: { owner: 'b' }, type: 'test', references: [] },
|
||||
],
|
||||
unauthorized: [{ id: '3', attributes: { owner: 'c' }, type: 'test', references: [] }],
|
||||
});
|
||||
|
||||
expect(mockLogger.log.mock.calls).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Array [
|
||||
Object {
|
||||
"event": Object {
|
||||
"action": "case_bulk_get",
|
||||
"category": Array [
|
||||
"database",
|
||||
],
|
||||
"outcome": "success",
|
||||
"type": Array [
|
||||
"access",
|
||||
],
|
||||
},
|
||||
"kibana": Object {
|
||||
"saved_object": Object {
|
||||
"id": "1",
|
||||
"type": "cases",
|
||||
},
|
||||
},
|
||||
"message": "User has accessed cases [id=1] as owner \\"a\\"",
|
||||
},
|
||||
],
|
||||
Array [
|
||||
Object {
|
||||
"event": Object {
|
||||
"action": "case_bulk_get",
|
||||
"category": Array [
|
||||
"database",
|
||||
],
|
||||
"outcome": "success",
|
||||
"type": Array [
|
||||
"access",
|
||||
],
|
||||
},
|
||||
"kibana": Object {
|
||||
"saved_object": Object {
|
||||
"id": "2",
|
||||
"type": "cases",
|
||||
},
|
||||
},
|
||||
"message": "User has accessed cases [id=2] as owner \\"b\\"",
|
||||
},
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasAllRequested: false', () => {
|
||||
const checkPrivilegesResponse = {
|
||||
hasAllRequested: false,
|
||||
username: 'super',
|
||||
privileges: {
|
||||
kibana: [
|
||||
{
|
||||
authorized: true,
|
||||
privilege: 'a:getCase',
|
||||
},
|
||||
{
|
||||
authorized: true,
|
||||
privilege: 'b:getCase',
|
||||
},
|
||||
{
|
||||
authorized: false,
|
||||
privilege: 'c:getCase',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
securityStart.authz.checkPrivilegesDynamicallyWithRequest.mockReturnValueOnce(
|
||||
jest.fn(async () => checkPrivilegesResponse)
|
||||
);
|
||||
|
||||
securityStart.authz.checkPrivilegesDynamicallyWithRequest.mockReturnValueOnce(
|
||||
jest.fn(async () => ({
|
||||
...checkPrivilegesResponse,
|
||||
hasAllRequested: true,
|
||||
}))
|
||||
);
|
||||
|
||||
(
|
||||
securityStart.authz.actions.cases.get as jest.MockedFunction<
|
||||
typeof securityStart.authz.actions.cases.get
|
||||
>
|
||||
).mockImplementation((owner, opName) => {
|
||||
return `${owner}:${opName}`;
|
||||
});
|
||||
|
||||
featuresStart.getKibanaFeatures.mockReturnValue([
|
||||
{ id: 'a', cases: ['a', 'b', 'c'] },
|
||||
] as unknown as KibanaFeature[]);
|
||||
|
||||
auth = await Authorization.create({
|
||||
request,
|
||||
securityAuth: securityStart.authz,
|
||||
spaces: spacesStart,
|
||||
features: featuresStart,
|
||||
auditLogger: new AuthorizationAuditLogger(mockLogger),
|
||||
logger: loggingSystemMock.createLogger(),
|
||||
});
|
||||
});
|
||||
|
||||
it('categorizes the registered owners a and b as authorized and the unregistered owner c as unauthorized', async () => {
|
||||
const res = await auth.getAndEnsureAuthorizedEntities({
|
||||
savedObjects: [
|
||||
{ id: '1', attributes: { owner: 'a' }, type: 'test', references: [] },
|
||||
{ id: '2', attributes: { owner: 'b' }, type: 'test', references: [] },
|
||||
{ id: '3', attributes: { owner: 'c' }, type: 'test', references: [] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(res).toEqual({
|
||||
authorized: [
|
||||
{ id: '1', attributes: { owner: 'a' }, type: 'test', references: [] },
|
||||
{ id: '2', attributes: { owner: 'b' }, type: 'test', references: [] },
|
||||
],
|
||||
unauthorized: [{ id: '3', attributes: { owner: 'c' }, type: 'test', references: [] }],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,15 +5,16 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { SavedObject } from '@kbn/core-saved-objects-common';
|
||||
import type { KibanaRequest, Logger } from '@kbn/core/server';
|
||||
import Boom from '@hapi/boom';
|
||||
import type { SecurityPluginStart } from '@kbn/security-plugin/server';
|
||||
import type { PluginStartContract as FeaturesPluginStart } from '@kbn/features-plugin/server';
|
||||
import type { Space, SpacesPluginStart } from '@kbn/spaces-plugin/server';
|
||||
import type { AuthFilterHelpers, OwnerEntity } from './types';
|
||||
import { getOwnersFilter } from './utils';
|
||||
import { getOwnersFilter, groupByAuthorization } from './utils';
|
||||
import type { OperationDetails } from '.';
|
||||
import { AuthorizationAuditLogger } from '.';
|
||||
import { AuthorizationAuditLogger, Operations } from '.';
|
||||
import { createCaseError } from '../common/error';
|
||||
|
||||
/**
|
||||
|
@ -107,23 +108,78 @@ export class Authorization {
|
|||
entities: OwnerEntity[];
|
||||
operation: OperationDetails;
|
||||
}) {
|
||||
const logSavedObjects = (error?: Error) => {
|
||||
for (const entity of entities) {
|
||||
this.auditLogger.log({ operation, error, entity });
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await this._ensureAuthorized(
|
||||
entities.map((entity) => entity.owner),
|
||||
operation
|
||||
);
|
||||
} catch (error) {
|
||||
logSavedObjects(error);
|
||||
this.logSavedObjects({ entities, operation, error });
|
||||
throw error;
|
||||
}
|
||||
|
||||
logSavedObjects();
|
||||
this.logSavedObjects({ entities, operation });
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Returns all authorized entities for an operation. It throws error if the user is not authorized
|
||||
* to any of the owners
|
||||
*
|
||||
* @param savedObjects an array of saved objects to be authorized. Each saved objects should contain
|
||||
* an ID and an owner
|
||||
*/
|
||||
public async getAndEnsureAuthorizedEntities<T extends { owner: string }>({
|
||||
savedObjects,
|
||||
}: {
|
||||
savedObjects: Array<SavedObject<T>>;
|
||||
}): 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,
|
||||
}));
|
||||
|
||||
const { authorizedOwners } = await this.getAuthorizedOwners([operation]);
|
||||
|
||||
if (!authorizedOwners.length) {
|
||||
const error = Boom.forbidden(
|
||||
AuthorizationAuditLogger.createFailureMessage({
|
||||
owners: [],
|
||||
operation,
|
||||
})
|
||||
);
|
||||
|
||||
this.logSavedObjects({ entities, error, operation });
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { authorized, unauthorized } = groupByAuthorization(savedObjects, authorizedOwners);
|
||||
|
||||
await this.ensureAuthorized({
|
||||
operation,
|
||||
entities: authorized.map((so) => ({
|
||||
owner: so.attributes.owner,
|
||||
id: so.id,
|
||||
})),
|
||||
});
|
||||
|
||||
return { authorized, unauthorized };
|
||||
}
|
||||
|
||||
private async logSavedObjects({
|
||||
entities,
|
||||
operation,
|
||||
error,
|
||||
}: {
|
||||
entities: OwnerEntity[];
|
||||
operation: OperationDetails;
|
||||
error?: Error;
|
||||
}) {
|
||||
for (const entity of entities) {
|
||||
this.auditLogger.log({ operation, error, entity });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -167,6 +167,14 @@ const CaseOperations = {
|
|||
docType: 'case',
|
||||
savedObjectType: CASE_SAVED_OBJECT,
|
||||
},
|
||||
[ReadOperations.BulkGetCases]: {
|
||||
ecsType: EVENT_TYPES.access,
|
||||
name: ACCESS_CASE_OPERATION,
|
||||
action: 'case_bulk_get',
|
||||
verbs: accessVerbs,
|
||||
docType: 'cases',
|
||||
savedObjectType: CASE_SAVED_OBJECT,
|
||||
},
|
||||
};
|
||||
|
||||
const ConfigurationOperations = {
|
||||
|
|
|
@ -15,6 +15,7 @@ export const createAuthorizationMock = () => {
|
|||
const mocked: AuthorizationMock = {
|
||||
ensureAuthorized: jest.fn(),
|
||||
getAuthorizationFilter: jest.fn(),
|
||||
getAndEnsureAuthorizedEntities: jest.fn(),
|
||||
};
|
||||
return mocked;
|
||||
};
|
||||
|
|
|
@ -28,6 +28,7 @@ export enum ReadOperations {
|
|||
GetCase = 'getCase',
|
||||
ResolveCase = 'resolveCase',
|
||||
FindCases = 'findCases',
|
||||
BulkGetCases = 'bulkGetCases',
|
||||
GetCaseIDsByAlertID = 'getCaseIDsByAlertID',
|
||||
GetCaseStatuses = 'getCaseStatuses',
|
||||
GetComment = 'getComment',
|
||||
|
|
|
@ -10,6 +10,7 @@ import { OWNER_FIELD } from '../../common/api';
|
|||
import {
|
||||
combineFilterWithAuthorizationFilter,
|
||||
ensureFieldIsSafeForQuery,
|
||||
groupByAuthorization,
|
||||
getOwnersFilter,
|
||||
includeFieldsRequiredForAuthentication,
|
||||
} from './utils';
|
||||
|
@ -276,4 +277,31 @@ describe('utils', () => {
|
|||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupByAuthorization', () => {
|
||||
const cases = [
|
||||
{ id: '1', type: 'cases', references: [], attributes: { owner: 'cases' } },
|
||||
{ id: '2', type: 'cases', references: [], attributes: { owner: 'securitySolution' } },
|
||||
{ id: '3', type: 'cases', references: [], attributes: { owner: 'securitySolution' } },
|
||||
];
|
||||
|
||||
it('partitions authorized and unauthorized cases correctly', () => {
|
||||
const authorizedOwners = ['cases'];
|
||||
|
||||
const res = groupByAuthorization(cases, authorizedOwners);
|
||||
expect(res).toEqual({ authorized: [cases[0]], unauthorized: [cases[1], cases[2]] });
|
||||
});
|
||||
|
||||
it('partitions authorized and unauthorized cases correctly when there are not authorized entities', () => {
|
||||
const res = groupByAuthorization(cases, []);
|
||||
expect(res).toEqual({ authorized: [], unauthorized: cases });
|
||||
});
|
||||
|
||||
it('partitions authorized and unauthorized cases correctly when there are no saved objects', () => {
|
||||
const authorizedOwners = ['cases'];
|
||||
|
||||
const res = groupByAuthorization([], authorizedOwners);
|
||||
expect(res).toEqual({ authorized: [], unauthorized: [] });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,9 +5,10 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { remove, uniq } from 'lodash';
|
||||
import { partition, remove, uniq } from 'lodash';
|
||||
import type { KueryNode } from '@kbn/es-query';
|
||||
import { nodeBuilder } from '@kbn/es-query';
|
||||
import type { SavedObject } from '@kbn/core-saved-objects-common';
|
||||
import { OWNER_FIELD } from '../../common/api';
|
||||
|
||||
export const getOwnersFilter = (
|
||||
|
@ -64,3 +65,14 @@ export const includeFieldsRequiredForAuthentication = (fields?: string[]): strin
|
|||
}
|
||||
return uniq([...fields, OWNER_FIELD]);
|
||||
};
|
||||
|
||||
export const groupByAuthorization = <T extends { owner: string }>(
|
||||
savedObjects: Array<SavedObject<T>>,
|
||||
authorizedOwners: string[]
|
||||
): { authorized: Array<SavedObject<T>>; unauthorized: Array<SavedObject<T>> } => {
|
||||
const [authorized, unauthorized] = partition(savedObjects, (so) =>
|
||||
authorizedOwners.includes(so.attributes.owner)
|
||||
);
|
||||
|
||||
return { authorized, unauthorized };
|
||||
};
|
||||
|
|
73
x-pack/plugins/cases/server/client/cases/bulk_get.test.ts
Normal file
73
x-pack/plugins/cases/server/client/cases/bulk_get.test.ts
Normal file
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* 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 { CaseResponse } from '../../../common/api';
|
||||
import { getTypeProps, CaseResponseRt } from '../../../common/api';
|
||||
import { mockCases } from '../../mocks';
|
||||
import { createCasesClientMockArgs } from '../mocks';
|
||||
import { bulkGet } from './bulk_get';
|
||||
|
||||
describe('bulkGet', () => {
|
||||
describe('throwErrorIfCaseIdsReachTheLimit', () => {
|
||||
const clientArgs = createCasesClientMockArgs();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('throws if the requested cases are more than 1000', async () => {
|
||||
const ids = Array(1001).fill('test');
|
||||
|
||||
await expect(bulkGet({ ids }, clientArgs)).rejects.toThrow(
|
||||
'Maximum request limit of 1000 cases reached'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('throwErrorIfFieldsAreInvalid', () => {
|
||||
const caseSO = mockCases[0];
|
||||
const clientArgs = createCasesClientMockArgs();
|
||||
clientArgs.services.caseService.getCases.mockResolvedValue({ saved_objects: [caseSO] });
|
||||
clientArgs.services.attachmentService.getCaseCommentStats.mockResolvedValue(new Map());
|
||||
|
||||
clientArgs.authorization.getAndEnsureAuthorizedEntities.mockResolvedValue({
|
||||
authorized: [caseSO],
|
||||
unauthorized: [],
|
||||
});
|
||||
|
||||
const typeProps = getTypeProps(CaseResponseRt) ?? {};
|
||||
const validFields = Object.keys(typeProps) as Array<keyof CaseResponse>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it.each(validFields)('supports valid field: %s', async (field) => {
|
||||
const ids = ['test'];
|
||||
|
||||
await expect(bulkGet({ ids, fields: [field] }, clientArgs)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('throws if the requested field is not valid', async () => {
|
||||
const ids = ['test'];
|
||||
|
||||
// @ts-expect-error
|
||||
await expect(bulkGet({ ids, fields: ['not-valid'] }, clientArgs)).rejects.toThrow(
|
||||
'Failed to bulk get cases: test: Error: Field: not-valid is not supported'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws for nested fields', async () => {
|
||||
const ids = ['test'];
|
||||
|
||||
// @ts-expect-error
|
||||
await expect(bulkGet({ ids, fields: ['created_by.username'] }, clientArgs)).rejects.toThrow(
|
||||
'Failed to bulk get cases: test: Error: Field: created_by.username is not supported'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
166
x-pack/plugins/cases/server/client/cases/bulk_get.ts
Normal file
166
x-pack/plugins/cases/server/client/cases/bulk_get.ts
Normal file
|
@ -0,0 +1,166 @@
|
|||
/*
|
||||
* 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 { pipe } from 'fp-ts/lib/pipeable';
|
||||
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,
|
||||
} from '../../../common/api';
|
||||
import {
|
||||
CasesBulkGetRequestRt,
|
||||
CasesResponseRt,
|
||||
excess,
|
||||
throwErrors,
|
||||
getTypeForCertainFieldsFromArray,
|
||||
CaseResponseRt,
|
||||
} from '../../../common/api';
|
||||
import { getTypeProps } from '../../../common/api/runtime_types';
|
||||
import { createCaseError } from '../../common/error';
|
||||
import { asArray, flattenCaseSavedObject } from '../../common/utils';
|
||||
import type { CasesClientArgs } from '../types';
|
||||
import { includeFieldsRequiredForAuthentication } from '../../authorization/utils';
|
||||
import type { CaseSavedObject } from '../../common/types';
|
||||
|
||||
type SOWithErrors = Array<CaseSavedObject & { error: SavedObjectError }>;
|
||||
|
||||
/**
|
||||
* Retrieves multiple cases by ids.
|
||||
*/
|
||||
export const bulkGet = async <Field extends keyof CaseResponse = keyof CaseResponse>(
|
||||
params: CasesBulkGetRequestCertainFields<Field>,
|
||||
clientArgs: CasesClientArgs
|
||||
): Promise<CasesBulkGetResponseCertainFields<Field>> => {
|
||||
const {
|
||||
services: { caseService, attachmentService },
|
||||
logger,
|
||||
authorization,
|
||||
unsecuredSavedObjectsClient,
|
||||
} = clientArgs;
|
||||
|
||||
try {
|
||||
const fields = includeFieldsRequiredForAuthentication(asArray(params.fields));
|
||||
|
||||
const request = pipe(
|
||||
excess(CasesBulkGetRequestRt).decode({ ...params, fields }),
|
||||
fold(throwErrors(Boom.badRequest), identity)
|
||||
);
|
||||
|
||||
throwErrorIfCaseIdsReachTheLimit(request.ids);
|
||||
throwErrorIfFieldsAreInvalid(fields);
|
||||
|
||||
const cases = await caseService.getCases({ caseIds: request.ids, fields });
|
||||
|
||||
const [validCases, soBulkGetErrors] = partition(
|
||||
cases.saved_objects,
|
||||
(caseInfo) => caseInfo.error === undefined
|
||||
) as [CaseSavedObject[], SOWithErrors];
|
||||
|
||||
const { authorized: authorizedCases, unauthorized: unauthorizedCases } =
|
||||
await authorization.getAndEnsureAuthorizedEntities({ savedObjects: validCases });
|
||||
|
||||
const requestForTotals = ['totalComment', 'totalAlerts'].some(
|
||||
(totalKey) => !fields || fields.includes(totalKey)
|
||||
);
|
||||
|
||||
const commentTotals = requestForTotals
|
||||
? await attachmentService.getCaseCommentStats({
|
||||
unsecuredSavedObjectsClient,
|
||||
caseIds: authorizedCases.map((theCase) => theCase.id),
|
||||
})
|
||||
: new Map();
|
||||
|
||||
const flattenedCases = authorizedCases.map((theCase) => {
|
||||
const { alerts, userComments } = commentTotals.get(theCase.id) ?? {
|
||||
alerts: 0,
|
||||
userComments: 0,
|
||||
};
|
||||
|
||||
const flattenedCase = flattenCaseSavedObject({
|
||||
savedObject: theCase,
|
||||
totalComment: userComments,
|
||||
totalAlerts: alerts,
|
||||
});
|
||||
|
||||
if (!fields?.length) {
|
||||
return flattenedCase;
|
||||
}
|
||||
|
||||
return pick(flattenedCase, [...fields, 'id', 'version']);
|
||||
});
|
||||
|
||||
const typeToEncode = getTypeForCertainFieldsFromArray(CasesResponseRt, fields);
|
||||
const casesToReturn = typeToEncode.encode(flattenedCases);
|
||||
|
||||
const errors = constructErrors(soBulkGetErrors, unauthorizedCases);
|
||||
|
||||
return { cases: casesToReturn, errors };
|
||||
} catch (error) {
|
||||
const ids = params.ids ?? [];
|
||||
throw createCaseError({
|
||||
message: `Failed to bulk get cases: ${ids.join(', ')}: ${error}`,
|
||||
error,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const throwErrorIfFieldsAreInvalid = (fields?: string[]) => {
|
||||
if (!fields || fields.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const typeProps = getTypeProps(CaseResponseRt) ?? {};
|
||||
const validFields = Object.keys(typeProps);
|
||||
|
||||
for (const field of fields) {
|
||||
if (!validFields.includes(field)) {
|
||||
throw Boom.badRequest(`Field: ${field} is not supported`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const throwErrorIfCaseIdsReachTheLimit = (ids: string[]) => {
|
||||
if (ids.length > MAX_BULK_GET_CASES) {
|
||||
throw Boom.badRequest(`Maximum request limit of ${MAX_BULK_GET_CASES} cases reached`);
|
||||
}
|
||||
};
|
||||
|
||||
const constructErrors = (
|
||||
soBulkGetErrors: SOWithErrors,
|
||||
unauthorizedCases: CaseSavedObject[]
|
||||
): CasesBulkGetResponse['errors'] => {
|
||||
const errors: CasesBulkGetResponse['errors'] = [];
|
||||
|
||||
for (const soError of soBulkGetErrors) {
|
||||
errors.push({
|
||||
error: soError.error.error,
|
||||
message: soError.error.message,
|
||||
status: soError.error.statusCode,
|
||||
caseId: soError.id,
|
||||
});
|
||||
}
|
||||
|
||||
for (const theCase of unauthorizedCases) {
|
||||
errors.push({
|
||||
error: 'Forbidden',
|
||||
message: `Unauthorized to access case with owner: "${theCase.attributes.owner}"`,
|
||||
status: 403,
|
||||
caseId: theCase.id,
|
||||
});
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
|
@ -13,6 +13,9 @@ import type {
|
|||
AllTagsFindRequest,
|
||||
AllReportersFindRequest,
|
||||
CasesByAlertId,
|
||||
CaseResponse,
|
||||
CasesBulkGetRequestCertainFields,
|
||||
CasesBulkGetResponseCertainFields,
|
||||
} from '../../../common/api';
|
||||
import type { CasesClient } from '../client';
|
||||
import type { CasesClientInternal } from '../client_internal';
|
||||
|
@ -26,6 +29,7 @@ import type {
|
|||
ICasesResponse,
|
||||
} from '../typedoc_interfaces';
|
||||
import type { CasesClientArgs } from '../types';
|
||||
import { bulkGet } from './bulk_get';
|
||||
import { create } from './create';
|
||||
import { deleteCases } from './delete';
|
||||
import { find } from './find';
|
||||
|
@ -58,6 +62,12 @@ export interface CasesSubClient {
|
|||
* Retrieves a single case resolving the specified ID.
|
||||
*/
|
||||
resolve(params: GetParams): Promise<ICaseResolveResponse>;
|
||||
/**
|
||||
* Retrieves multiple cases with the specified IDs.
|
||||
*/
|
||||
bulkGet<Field extends keyof CaseResponse = keyof CaseResponse>(
|
||||
params: CasesBulkGetRequestCertainFields<Field | 'id' | 'version' | 'owner'>
|
||||
): Promise<CasesBulkGetResponseCertainFields<Field | 'id' | 'version' | 'owner'>>;
|
||||
/**
|
||||
* Pushes a specific case to an external system.
|
||||
*/
|
||||
|
@ -101,6 +111,7 @@ export const createCasesSubClient = (
|
|||
find: (params: CasesFindRequest) => find(params, clientArgs),
|
||||
get: (params: GetParams) => get(params, clientArgs),
|
||||
resolve: (params: GetParams) => resolve(params, clientArgs),
|
||||
bulkGet: (params) => bulkGet(params, clientArgs),
|
||||
push: (params: PushParams) => push(params, clientArgs, casesClient, casesClientInternal),
|
||||
update: (cases: CasesPatchRequest) => update(cases, clientArgs),
|
||||
delete: (ids: string[]) => deleteCases(ids, clientArgs),
|
||||
|
|
|
@ -42,6 +42,7 @@ const createCasesSubClientMock = (): CasesSubClientMock => {
|
|||
find: jest.fn(),
|
||||
resolve: jest.fn(),
|
||||
get: jest.fn(),
|
||||
bulkGet: jest.fn(),
|
||||
push: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
|
|
|
@ -27,6 +27,7 @@ import type {
|
|||
CasesResponse,
|
||||
CaseUserActionsResponse,
|
||||
CommentsResponse,
|
||||
CasesBulkGetResponse,
|
||||
} from '../../common/api';
|
||||
|
||||
/**
|
||||
|
@ -41,6 +42,7 @@ export interface ICaseResponse extends CaseResponse {}
|
|||
export interface ICaseResolveResponse extends CaseResolveResponse {}
|
||||
export interface ICasesResponse extends CasesResponse {}
|
||||
export interface ICasesFindResponse extends CasesFindResponse {}
|
||||
export interface ICasesBulkGetResponse extends CasesBulkGetResponse {}
|
||||
|
||||
export interface ICasesConfigureResponse extends CasesConfigureResponse {}
|
||||
export interface ICasesConfigureRequest extends CasesConfigureRequest {}
|
||||
|
|
|
@ -7,8 +7,13 @@
|
|||
|
||||
import type { UserProfileService } from '../../services';
|
||||
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';
|
||||
|
||||
export const getInternalRoutes = (userProfileService: UserProfileService) =>
|
||||
[bulkCreateAttachmentsRoute, suggestUserProfilesRoute(userProfileService)] as CaseRoute[];
|
||||
[
|
||||
bulkCreateAttachmentsRoute,
|
||||
suggestUserProfilesRoute(userProfileService),
|
||||
bulkGetCasesRoute,
|
||||
] as CaseRoute[];
|
||||
|
|
|
@ -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 { INTERNAL_BULK_GET_CASES_URL } from '../../../../common/constants';
|
||||
import type { CasesBulkGetRequestCertainFields } from '../../../../common/api';
|
||||
import { createCaseError } from '../../../common/error';
|
||||
import { createCasesRoute } from '../create_cases_route';
|
||||
import { escapeHatch } from '../utils';
|
||||
|
||||
export const bulkGetCasesRoute = createCasesRoute({
|
||||
method: 'post',
|
||||
path: INTERNAL_BULK_GET_CASES_URL,
|
||||
params: {
|
||||
body: escapeHatch,
|
||||
},
|
||||
handler: async ({ context, request, response }) => {
|
||||
const params = request.body as CasesBulkGetRequestCertainFields;
|
||||
|
||||
try {
|
||||
const casesContext = await context.cases;
|
||||
const casesClient = await casesContext.getCasesClient();
|
||||
|
||||
return response.ok({
|
||||
body: await casesClient.cases.bulkGet({ ...params }),
|
||||
});
|
||||
} catch (error) {
|
||||
const ids = params.ids ?? [];
|
||||
throw createCaseError({
|
||||
message: `Failed to bulk get cases in route: ${ids.join(', ')}: ${error}`,
|
||||
error,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
|
@ -75,6 +75,7 @@ interface DeleteCaseArgs extends GetCaseArgs, IndexRefresh {}
|
|||
|
||||
interface GetCasesArgs {
|
||||
caseIds: string[];
|
||||
fields?: string[];
|
||||
}
|
||||
|
||||
interface FindCommentsArgs {
|
||||
|
@ -361,11 +362,12 @@ export class CasesService {
|
|||
|
||||
public async getCases({
|
||||
caseIds,
|
||||
fields,
|
||||
}: GetCasesArgs): Promise<SavedObjectsBulkResponse<CaseAttributes>> {
|
||||
try {
|
||||
this.log.debug(`Attempting to GET cases ${caseIds.join(', ')}`);
|
||||
const cases = await this.unsecuredSavedObjectsClient.bulkGet<ESCaseAttributes>(
|
||||
caseIds.map((caseId) => ({ type: CASE_SAVED_OBJECT, id: caseId }))
|
||||
caseIds.map((caseId) => ({ type: CASE_SAVED_OBJECT, id: caseId, fields }))
|
||||
);
|
||||
return transformBulkResponseToExternalModel(cases);
|
||||
} catch (error) {
|
||||
|
|
|
@ -53,6 +53,7 @@ import {
|
|||
BulkCreateCommentRequest,
|
||||
CommentType,
|
||||
CasesMetricsResponse,
|
||||
CasesBulkGetResponse,
|
||||
} from '@kbn/cases-plugin/common/api';
|
||||
import { getCaseUserActionUrl } from '@kbn/cases-plugin/common/api/helpers';
|
||||
import { SignalHit } from '@kbn/security-solution-plugin/server/lib/detection_engine/signals/types';
|
||||
|
@ -1418,3 +1419,26 @@ export const getReferenceFromEsResponse = (
|
|||
esResponse: TransportResult<GetResponse<SavedObjectsRawDocSource>, unknown>,
|
||||
id: string
|
||||
) => esResponse.body._source?.references?.find((r) => r.id === id);
|
||||
|
||||
export const bulkGetCases = async ({
|
||||
supertest,
|
||||
ids,
|
||||
fields,
|
||||
expectedHttpCode = 200,
|
||||
auth = { user: superUser, space: null },
|
||||
}: {
|
||||
supertest: SuperTest.SuperTest<SuperTest.Test>;
|
||||
ids: string[];
|
||||
fields?: string[];
|
||||
expectedHttpCode?: number;
|
||||
auth?: { user: User; space: string | null };
|
||||
}): Promise<CasesBulkGetResponse> => {
|
||||
const { body: res } = await supertest
|
||||
.post(`${getSpaceUrlPrefix(auth.space)}${CASES_INTERNAL_URL}/_bulk_get`)
|
||||
.auth(auth.user.username, auth.user.password)
|
||||
.set('kbn-xsrf', 'true')
|
||||
.send({ ids, fields })
|
||||
.expect(expectedHttpCode);
|
||||
|
||||
return res;
|
||||
};
|
||||
|
|
|
@ -44,6 +44,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => {
|
|||
*/
|
||||
|
||||
loadTestFile(require.resolve('./internal/bulk_create_attachments'));
|
||||
loadTestFile(require.resolve('./internal/bulk_get_cases'));
|
||||
|
||||
/**
|
||||
* Attachments framework
|
||||
|
|
|
@ -0,0 +1,344 @@
|
|||
/*
|
||||
* 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 { pick } from 'lodash';
|
||||
import expect from '@kbn/expect';
|
||||
import { CommentType } from '@kbn/cases-plugin/common';
|
||||
import { getPostCaseRequest, postCaseReq } from '../../../../common/lib/mock';
|
||||
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
|
||||
import {
|
||||
deleteAllCaseItems,
|
||||
bulkGetCases,
|
||||
createCase,
|
||||
createComment,
|
||||
ensureSavedObjectIsAuthorized,
|
||||
} from '../../../../common/lib/utils';
|
||||
import {
|
||||
secOnly,
|
||||
obsOnly,
|
||||
superUser,
|
||||
secOnlyRead,
|
||||
obsOnlyRead,
|
||||
obsSecRead,
|
||||
globalRead,
|
||||
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('bulk_get_cases', () => {
|
||||
afterEach(async () => {
|
||||
await deleteAllCaseItems(es);
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('should return the correct cases', async () => {
|
||||
const caseOne = await createCase(supertest, postCaseReq);
|
||||
const caseTwo = await createCase(supertest, postCaseReq);
|
||||
|
||||
const { cases, errors } = await bulkGetCases({ supertest, ids: [caseOne.id, caseTwo.id] });
|
||||
|
||||
expect(cases).to.eql([caseOne, caseTwo]);
|
||||
expect(errors.length).to.be(0);
|
||||
});
|
||||
|
||||
it('should return the correct cases with specific fields', async () => {
|
||||
const caseOne = await createCase(supertest, postCaseReq);
|
||||
const caseTwo = await createCase(supertest, postCaseReq);
|
||||
|
||||
const { cases, errors } = await bulkGetCases({
|
||||
supertest,
|
||||
ids: [caseOne.id, caseTwo.id],
|
||||
fields: ['title'],
|
||||
});
|
||||
|
||||
const fieldsToPick = ['id', 'version', 'owner', 'title'];
|
||||
|
||||
expect(cases).to.eql([pick(caseOne, fieldsToPick), pick(caseTwo, fieldsToPick)]);
|
||||
expect(errors.length).to.be(0);
|
||||
});
|
||||
|
||||
it('should return only valid cases', async () => {
|
||||
const caseOne = await createCase(supertest, postCaseReq);
|
||||
|
||||
const { cases, errors } = await bulkGetCases({ supertest, ids: [caseOne.id, 'not-exist'] });
|
||||
|
||||
expect(cases).to.eql([caseOne]);
|
||||
expect(errors.length).to.be(1);
|
||||
});
|
||||
|
||||
it('should return the correct counts', async () => {
|
||||
const caseOne = await createCase(supertest, postCaseReq);
|
||||
const caseTwo = await createCase(supertest, postCaseReq);
|
||||
|
||||
await createComment({
|
||||
supertest,
|
||||
caseId: caseOne.id,
|
||||
params: {
|
||||
alertId: ['test-id-1', 'test-id-2'],
|
||||
index: ['test-index', 'test-index'],
|
||||
rule: { id: 'test-rule-id', name: 'test-index-id' },
|
||||
type: CommentType.alert,
|
||||
owner: 'securitySolutionFixture',
|
||||
},
|
||||
});
|
||||
|
||||
const caseTwoUpdated = await createComment({
|
||||
supertest,
|
||||
caseId: caseTwo.id,
|
||||
params: {
|
||||
alertId: ['test-id-3'],
|
||||
index: ['test-index'],
|
||||
rule: { id: 'test-rule-id', name: 'test-index-id' },
|
||||
type: CommentType.alert,
|
||||
owner: 'securitySolutionFixture',
|
||||
},
|
||||
});
|
||||
|
||||
const caseOneUpdated = await createComment({
|
||||
supertest,
|
||||
caseId: caseOne.id,
|
||||
params: {
|
||||
comment: 'a comment',
|
||||
type: CommentType.user,
|
||||
owner: 'securitySolutionFixture',
|
||||
},
|
||||
});
|
||||
|
||||
const { cases, errors } = await bulkGetCases({
|
||||
supertest,
|
||||
ids: [caseOneUpdated.id, caseTwoUpdated.id],
|
||||
});
|
||||
|
||||
/**
|
||||
* For performance reasons bulk_get does not
|
||||
* return the comments
|
||||
*/
|
||||
expect(cases).to.eql([
|
||||
{ ...caseOneUpdated, comments: [], totalComment: 1, totalAlerts: 2 },
|
||||
{ ...caseTwoUpdated, comments: [], totalComment: 0, totalAlerts: 1 },
|
||||
]);
|
||||
expect(errors.length).to.be(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('errors', () => {
|
||||
it('400s when requesting invalid fields', async () => {
|
||||
const caseOne = await createCase(supertest, postCaseReq);
|
||||
|
||||
await bulkGetCases({
|
||||
supertest,
|
||||
ids: [caseOne.id],
|
||||
fields: ['invalid'],
|
||||
expectedHttpCode: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it('400s when requesting more than 1000 cases', async () => {
|
||||
const ids = Array(1001).fill('test');
|
||||
|
||||
await bulkGetCases({
|
||||
supertest,
|
||||
ids,
|
||||
expectedHttpCode: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return saved object errors correctly', async () => {
|
||||
const caseOne = await createCase(supertest, postCaseReq);
|
||||
|
||||
const { errors } = await bulkGetCases({ supertest, ids: [caseOne.id, 'not-exist'] });
|
||||
|
||||
expect(errors.length).to.be(1);
|
||||
expect(errors[0]).to.eql({
|
||||
error: 'Not Found',
|
||||
message: 'Saved object [cases/not-exist] not found',
|
||||
status: 404,
|
||||
caseId: 'not-exist',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('rbac', () => {
|
||||
it('should return the correct cases', async () => {
|
||||
const [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',
|
||||
}
|
||||
),
|
||||
]);
|
||||
|
||||
const caseIds = [secCase.id, obsCase.id];
|
||||
|
||||
for (const scenario of [
|
||||
{
|
||||
user: globalRead,
|
||||
numberOfExpectedCases: 2,
|
||||
owners: ['securitySolutionFixture', 'observabilityFixture'],
|
||||
errors: { totals: 0, forCaseId: '', forOwner: '' },
|
||||
},
|
||||
{
|
||||
user: superUser,
|
||||
numberOfExpectedCases: 2,
|
||||
owners: ['securitySolutionFixture', 'observabilityFixture'],
|
||||
errors: { totals: 0, forCaseId: '', forOwner: '' },
|
||||
},
|
||||
{
|
||||
user: secOnlyRead,
|
||||
numberOfExpectedCases: 1,
|
||||
owners: ['securitySolutionFixture'],
|
||||
errors: { totals: 1, forCaseId: obsCase.id, forOwner: obsCase.owner },
|
||||
},
|
||||
{
|
||||
user: obsOnlyRead,
|
||||
numberOfExpectedCases: 1,
|
||||
owners: ['observabilityFixture'],
|
||||
errors: { totals: 1, forCaseId: secCase.id, forOwner: secCase.owner },
|
||||
},
|
||||
{
|
||||
user: obsSecRead,
|
||||
numberOfExpectedCases: 2,
|
||||
owners: ['securitySolutionFixture', 'observabilityFixture'],
|
||||
errors: { totals: 0, forCaseId: '', forOwner: '' },
|
||||
},
|
||||
{
|
||||
user: obsSec,
|
||||
numberOfExpectedCases: 2,
|
||||
owners: ['securitySolutionFixture', 'observabilityFixture'],
|
||||
errors: { totals: 0, forCaseId: '', forOwner: '' },
|
||||
},
|
||||
{
|
||||
user: secOnly,
|
||||
numberOfExpectedCases: 1,
|
||||
owners: ['securitySolutionFixture'],
|
||||
errors: { totals: 1, forCaseId: obsCase.id, forOwner: obsCase.owner },
|
||||
},
|
||||
{
|
||||
user: obsOnly,
|
||||
numberOfExpectedCases: 1,
|
||||
owners: ['observabilityFixture'],
|
||||
errors: { totals: 1, forCaseId: secCase.id, forOwner: secCase.owner },
|
||||
},
|
||||
]) {
|
||||
const { cases, errors } = await bulkGetCases({
|
||||
supertest: supertestWithoutAuth,
|
||||
ids: caseIds,
|
||||
auth: { user: scenario.user, space: 'space1' },
|
||||
});
|
||||
|
||||
ensureSavedObjectIsAuthorized(cases, scenario.numberOfExpectedCases, scenario.owners);
|
||||
expect(errors.length).to.be(scenario.errors.totals);
|
||||
|
||||
if (scenario.errors.totals) {
|
||||
expect(errors[0]).to.eql({
|
||||
error: 'Forbidden',
|
||||
message: `Unauthorized to access case with owner: "${scenario.errors.forOwner}"`,
|
||||
status: 403,
|
||||
caseId: scenario.errors.forCaseId,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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 cases`, async () => {
|
||||
// super user creates a case at the appropriate space
|
||||
const newCase = await createCase(
|
||||
supertestWithoutAuth,
|
||||
getPostCaseRequest({ owner: 'securitySolutionFixture' }),
|
||||
200,
|
||||
{
|
||||
user: superUser,
|
||||
space: scenario.space,
|
||||
}
|
||||
);
|
||||
|
||||
await bulkGetCases({
|
||||
supertest: supertestWithoutAuth,
|
||||
ids: [newCase.id],
|
||||
auth: {
|
||||
user: scenario.user,
|
||||
space: scenario.space,
|
||||
},
|
||||
expectedHttpCode: 403,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it('should get an empty array when the user does not have access to owner', async () => {
|
||||
const newCase = await createCase(
|
||||
supertestWithoutAuth,
|
||||
getPostCaseRequest({ owner: 'securitySolutionFixture' }),
|
||||
200,
|
||||
{
|
||||
user: superUser,
|
||||
space: 'space1',
|
||||
}
|
||||
);
|
||||
|
||||
for (const user of [obsOnly, obsOnlyRead]) {
|
||||
const { cases, errors } = await bulkGetCases({
|
||||
supertest: supertestWithoutAuth,
|
||||
ids: [newCase.id],
|
||||
auth: { user, space: 'space1' },
|
||||
});
|
||||
|
||||
expect(cases.length).to.be(0);
|
||||
expect(errors.length).to.be(1);
|
||||
|
||||
expect(errors[0]).to.eql({
|
||||
error: 'Forbidden',
|
||||
message: 'Unauthorized to access case with owner: "securitySolutionFixture"',
|
||||
status: 403,
|
||||
caseId: newCase.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should NOT request the namespace field', async () => {
|
||||
const newCase = await createCase(
|
||||
supertestWithoutAuth,
|
||||
getPostCaseRequest({ owner: 'securitySolutionFixture' }),
|
||||
200
|
||||
);
|
||||
|
||||
await bulkGetCases({
|
||||
supertest: supertestWithoutAuth,
|
||||
ids: [newCase.id],
|
||||
fields: ['namespace'],
|
||||
expectedHttpCode: 400,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue