[Cases] Hide cases in stack management UI (#163037)

## Summary

fixes https://github.com/elastic/kibana/issues/160337

This PR 
- hides cases in the serverless Elasticsearch project, cases APIs throw
error
- throws 403 from API when `owner=cases` for security or observability
serverless mode
- verifies the behaviour in serverless functional as well as
api_integration tests

**How to test**

- Boot up `es` serverless solution and make sure that `cases` from the
navbar is hidden and cannot not be accessible through url as well
- Boot up `observability` or `security` serverless solutions and make
sure that `cases` is available in the navbar and works fine
- Boot up classic kibana and make sure that the left navbar has the same
menu entries it always had.

### 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



20c1974e-44f0-45b0-80aa-e644fec148ff

### 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)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Janki Salvi 2023-08-09 17:52:36 +02:00 committed by GitHub
parent f4856f7478
commit dc949ee373
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1364 additions and 23 deletions

View file

@ -90,3 +90,6 @@ vis_type_timeseries.readOnly: true
vis_type_vislib.readOnly: true
vis_type_xy.readOnly: true
input_control_vis.readOnly: true
# Disable cases in stack management
xpack.cases.stack.enabled: false

View file

@ -197,6 +197,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) {
'xpack.cases.files.allowedMimeTypes (array)',
'xpack.cases.files.maxSize (number)',
'xpack.cases.markdownPlugins.lens (boolean)',
'xpack.cases.stack.enabled (boolean)',
'xpack.ccr.ui.enabled (boolean)',
'xpack.cloud.base_url (string)',
'xpack.cloud.cname (string)',

View file

@ -59,6 +59,9 @@ export interface CasesUiConfigType {
maxSize?: number;
allowedMimeTypes: string[];
};
stack: {
enabled: boolean;
};
}
export const StatusAll = 'all' as const;

View file

@ -0,0 +1,154 @@
/*
* 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 { PluginInitializerContext } from '@kbn/core/public';
import { coreMock } from '@kbn/core/public/mocks';
import { licensingMock } from '@kbn/licensing-plugin/public/mocks';
import { featuresPluginMock } from '@kbn/features-plugin/public/mocks';
import { securityMock } from '@kbn/security-plugin/public/mocks';
import { managementPluginMock } from '@kbn/management-plugin/public/mocks';
import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks';
import { lensPluginMock } from '@kbn/lens-plugin/public/mocks';
import { contentManagementMock } from '@kbn/content-management-plugin/public/mocks';
import { mockStorage } from '@kbn/kibana-utils-plugin/public/storage/hashed_item_store/mock';
import { triggersActionsUiMock } from '@kbn/triggers-actions-ui-plugin/public/mocks';
import type { CasesPluginSetup, CasesPluginStart } from './types';
import { CasesUiPlugin } from './plugin';
import { ALLOWED_MIME_TYPES } from '../common/constants/mime_types';
function getConfig(overrides = {}) {
return {
markdownPlugins: { lens: true },
files: { maxSize: 1, allowedMimeTypes: ALLOWED_MIME_TYPES },
stack: { enabled: true },
...overrides,
};
}
describe('Cases Ui Plugin', () => {
let context: PluginInitializerContext;
let plugin: CasesUiPlugin;
let coreSetup: ReturnType<typeof coreMock.createSetup>;
let coreStart: ReturnType<typeof coreMock.createStart>;
let pluginsSetup: jest.Mocked<CasesPluginSetup>;
let pluginsStart: jest.Mocked<CasesPluginStart>;
beforeEach(() => {
context = coreMock.createPluginInitializerContext(getConfig());
plugin = new CasesUiPlugin(context);
coreSetup = coreMock.createSetup();
coreStart = coreMock.createStart();
pluginsSetup = {
files: {
filesClientFactory: { asScoped: jest.fn(), asUnscoped: jest.fn() },
registerFileKind: jest.fn(),
},
security: securityMock.createSetup(),
management: managementPluginMock.createSetupContract(),
};
pluginsStart = {
licensing: licensingMock.createStart(),
uiActions: uiActionsPluginMock.createStartContract(),
files: {
filesClientFactory: { asScoped: jest.fn(), asUnscoped: jest.fn() },
getAllFindKindDefinitions: jest.fn(),
getFileKindDefinition: jest.fn(),
},
features: featuresPluginMock.createStart(),
security: securityMock.createStart(),
data: dataPluginMock.createStartContract(),
embeddable: embeddablePluginMock.createStartContract(),
lens: lensPluginMock.createStartContract(),
contentManagement: contentManagementMock.createStartContract(),
storage: {
store: {
getItem: mockStorage.getItem,
setItem: mockStorage.setItem,
removeItem: mockStorage.removeItem,
clear: mockStorage.clear,
},
get: jest.fn(),
set: jest.fn(),
clear: jest.fn(),
remove: jest.fn(),
},
triggersActionsUi: triggersActionsUiMock.createStart(),
};
});
describe('setup()', () => {
it('should start setup cases plugin correctly', async () => {
const setup = plugin.setup(coreSetup, pluginsSetup);
expect(setup).toMatchInlineSnapshot(`
Object {
"attachmentFramework": Object {
"registerExternalReference": [Function],
"registerPersistableState": [Function],
},
}
`);
});
it('should register kibana feature when stack is enabled', async () => {
plugin.setup(coreSetup, pluginsSetup);
expect(
pluginsSetup.management.sections.section.insightsAndAlerting.registerApp
).toHaveBeenCalled();
});
it('should not register kibana feature when stack is disabled', async () => {
context = coreMock.createPluginInitializerContext(getConfig({ stack: { enabled: false } }));
const pluginWithStackDisabled = new CasesUiPlugin(context);
pluginWithStackDisabled.setup(coreSetup, pluginsSetup);
expect(
pluginsSetup.management.sections.section.insightsAndAlerting.registerApp
).not.toHaveBeenCalled();
});
});
describe('start', () => {
it('should start cases plugin correctly', async () => {
const pluginStart = plugin.start(coreStart, pluginsStart);
expect(pluginStart).toStrictEqual({
api: {
cases: {
bulkGet: expect.any(Function),
find: expect.any(Function),
getCasesMetrics: expect.any(Function),
getCasesStatus: expect.any(Function),
},
getRelatedCases: expect.any(Function),
},
helpers: {
canUseCases: expect.any(Function),
getRuleIdFromEvent: expect.any(Function),
getUICapabilities: expect.any(Function),
groupAlertsByRule: expect.any(Function),
},
hooks: {
useCasesAddToExistingCaseModal: expect.any(Function),
useCasesAddToNewCaseFlyout: expect.any(Function),
},
ui: {
getAllCasesSelectorModal: expect.any(Function),
getCases: expect.any(Function),
getCasesContext: expect.any(Function),
getRecentCases: expect.any(Function),
},
});
});
});
});

View file

@ -75,30 +75,32 @@ export class CasesUiPlugin
});
}
plugins.management.sections.section.insightsAndAlerting.registerApp({
id: APP_ID,
title: APP_TITLE,
order: 1,
async mount(params: ManagementAppMountParams) {
const [coreStart, pluginsStart] = (await core.getStartServices()) as [
CoreStart,
CasesPluginStart,
unknown
];
if (config.stack.enabled) {
plugins.management.sections.section.insightsAndAlerting.registerApp({
id: APP_ID,
title: APP_TITLE,
order: 1,
async mount(params: ManagementAppMountParams) {
const [coreStart, pluginsStart] = (await core.getStartServices()) as [
CoreStart,
CasesPluginStart,
unknown
];
const { renderApp } = await import('./application');
const { renderApp } = await import('./application');
return renderApp({
mountParams: params,
coreStart,
pluginsStart,
storage,
kibanaVersion,
externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry,
});
},
});
return renderApp({
mountParams: params,
coreStart,
pluginsStart,
storage,
kibanaVersion,
externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry,
});
},
});
}
return {
attachmentFramework: {

View file

@ -106,6 +106,9 @@ describe('config validation', () => {
"markdownPlugins": Object {
"lens": true,
},
"stack": Object {
"enabled": true,
},
}
`);
});

View file

@ -20,6 +20,9 @@ export const ConfigSchema = schema.object({
// intentionally not setting a default here so that we can determine if the user set it
maxSize: schema.maybe(schema.number({ min: 0 })),
}),
stack: schema.object({
enabled: schema.boolean({ defaultValue: true }),
}),
});
export type ConfigType = TypeOf<typeof ConfigSchema>;

View file

@ -16,6 +16,7 @@ export const config: PluginConfigDescriptor<ConfigType> = {
exposeToBrowser: {
markdownPlugins: true,
files: { maxSize: true, allowedMimeTypes: true },
stack: { enabled: true },
},
deprecations: ({ renameFromRoot }) => [
renameFromRoot('xpack.case.enabled', 'xpack.cases.enabled', { level: 'critical' }),

View file

@ -0,0 +1,123 @@
/*
* 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 { PluginInitializerContext } from '@kbn/core/server';
import {} from '@kbn/core/server';
import { coreMock } from '@kbn/core/server/mocks';
import { usageCollectionPluginMock } from '@kbn/usage-collection-plugin/server/mocks';
import { licensingMock } from '@kbn/licensing-plugin/server/mocks';
import { featuresPluginMock } from '@kbn/features-plugin/server/mocks';
import { createFilesSetupMock } from '@kbn/files-plugin/server/mocks';
import { securityMock } from '@kbn/security-plugin/server/mocks';
import { makeLensEmbeddableFactory } from '@kbn/lens-plugin/server/embeddable/make_lens_embeddable_factory';
import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks';
import { actionsMock } from '@kbn/actions-plugin/server/mocks';
import { notificationsMock } from '@kbn/notifications-plugin/server/mocks';
import { alertsMock } from '@kbn/alerting-plugin/server/mocks';
import type { PluginsSetup, PluginsStart } from './plugin';
import { CasePlugin } from './plugin';
import type { ConfigType } from './config';
import { ALLOWED_MIME_TYPES } from '../common/constants/mime_types';
function getConfig(overrides = {}) {
return {
markdownPlugins: { lens: true },
files: { maxSize: 1, allowedMimeTypes: ALLOWED_MIME_TYPES },
stack: { enabled: true },
...overrides,
};
}
describe('Cases Plugin', () => {
let context: PluginInitializerContext;
let plugin: CasePlugin;
let coreSetup: ReturnType<typeof coreMock.createSetup>;
let coreStart: ReturnType<typeof coreMock.createStart>;
let pluginsSetup: jest.Mocked<PluginsSetup>;
let pluginsStart: jest.Mocked<PluginsStart>;
beforeEach(() => {
context = coreMock.createPluginInitializerContext<ConfigType>(getConfig());
plugin = new CasePlugin(context);
coreSetup = coreMock.createSetup();
coreStart = coreMock.createStart();
pluginsSetup = {
taskManager: taskManagerMock.createSetup(),
actions: actionsMock.createSetup(),
files: createFilesSetupMock(),
lens: {
lensEmbeddableFactory: makeLensEmbeddableFactory(
() => ({}),
() => ({}),
{}
),
registerVisualizationMigration: jest.fn(),
},
security: securityMock.createSetup(),
licensing: licensingMock.createSetup(),
usageCollection: usageCollectionPluginMock.createSetupContract(),
features: featuresPluginMock.createSetup(),
};
pluginsStart = {
licensing: licensingMock.createStart(),
actions: actionsMock.createStart(),
files: { fileServiceFactory: { asScoped: jest.fn(), asInternal: jest.fn() } },
features: featuresPluginMock.createStart(),
security: securityMock.createStart(),
notifications: notificationsMock.createStart(),
ruleRegistry: { getRacClientWithRequest: jest.fn(), alerting: alertsMock.createStart() },
};
});
describe('setup()', () => {
it('should start setup cases plugin correctly', async () => {
plugin.setup(coreSetup, pluginsSetup);
expect(context.logger.get().debug).toHaveBeenCalledWith(
`Setting up Case Workflow with core contract [${Object.keys(
coreSetup
)}] and plugins [${Object.keys(pluginsSetup)}]`
);
});
it('should register kibana feature when stack is enabled', async () => {
plugin.setup(coreSetup, pluginsSetup);
expect(pluginsSetup.features.registerKibanaFeature).toHaveBeenCalled();
});
it('should not register kibana feature when stack is disabled', async () => {
context = coreMock.createPluginInitializerContext<ConfigType>(
getConfig({ stack: { enabled: false } })
);
const pluginWithStackDisabled = new CasePlugin(context);
pluginWithStackDisabled.setup(coreSetup, pluginsSetup);
expect(pluginsSetup.features.registerKibanaFeature).not.toHaveBeenCalled();
});
});
describe('start', () => {
it('should start cases plugin correctly', async () => {
const pluginStart = plugin.start(coreStart, pluginsStart);
expect(context.logger.get().debug).toHaveBeenCalledWith(`Starting Case Workflow`);
expect(pluginStart).toMatchInlineSnapshot(`
Object {
"getCasesClientWithRequest": [Function],
"getExternalReferenceAttachmentTypeRegistry": [Function],
"getPersistableStateAttachmentTypeRegistry": [Function],
}
`);
});
});
});

View file

@ -121,7 +121,9 @@ export class CasePlugin {
this.securityPluginSetup = plugins.security;
this.lensEmbeddableFactory = plugins.lens.lensEmbeddableFactory;
plugins.features.registerKibanaFeature(getCasesKibanaFeature());
if (this.caseConfig.stack.enabled) {
plugins.features.registerKibanaFeature(getCasesKibanaFeature());
}
core.savedObjects.registerType(
createCaseCommentSavedObjectType({

View file

@ -68,6 +68,7 @@
"@kbn/core-theme-browser",
"@kbn/serverless",
"@kbn/core-http-server",
"@kbn/alerting-plugin",
"@kbn/content-management-plugin",
],
"exclude": [

View file

@ -0,0 +1,57 @@
/*
* 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 '../../../ftr_provider_context';
import {
findCases,
createCase,
deleteAllCaseItems,
postCaseReq,
findCasesResp,
} from './helpers/api';
export default ({ getService }: FtrProviderContext): void => {
const supertest = getService('supertest');
const es = getService('es');
describe('find_cases', () => {
afterEach(async () => {
await deleteAllCaseItems(es);
});
it('should return empty response', async () => {
const cases = await findCases({ supertest });
expect(cases).to.eql(findCasesResp);
});
it('should return cases', async () => {
const a = await createCase(supertest, postCaseReq);
const b = await createCase(supertest, postCaseReq);
const c = await createCase(supertest, postCaseReq);
const cases = await findCases({ supertest });
expect(cases).to.eql({
...findCasesResp,
total: 3,
cases: [a, b, c],
count_open_cases: 3,
});
});
it('returns empty response when trying to find cases with owner as cases', async () => {
const cases = await findCases({ supertest, query: { owner: 'cases' } });
expect(cases).to.eql(findCasesResp);
});
it('returns empty response when trying to find cases with owner as securitySolution', async () => {
const cases = await findCases({ supertest, query: { owner: 'securitySolution' } });
expect(cases).to.eql(findCasesResp);
});
});
};

View file

@ -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 expect from '@kbn/expect';
import { FtrProviderContext } from '../../../ftr_provider_context';
import {
getCase,
createCase,
deleteCasesByESQuery,
getPostCaseRequest,
postCaseResp,
} from './helpers/api';
import { removeServerGeneratedPropertiesFromCase } from './helpers/omit';
export default ({ getService }: FtrProviderContext): void => {
const supertest = getService('supertest');
const es = getService('es');
describe('get_case', () => {
afterEach(async () => {
await deleteCasesByESQuery(es);
});
it('should return a case', async () => {
const postedCase = await createCase(supertest, getPostCaseRequest());
const theCase = await getCase({ supertest, caseId: postedCase.id, includeComments: true });
const data = removeServerGeneratedPropertiesFromCase(theCase);
expect(data).to.eql(postCaseResp());
expect(data.comments?.length).to.eql(0);
});
});
};

View file

@ -0,0 +1,247 @@
/*
* 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 { Client } from '@elastic/elasticsearch';
import type SuperTest from 'supertest';
import { ALERTING_CASES_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server/src/saved_objects_index_pattern';
import { CASES_URL } from '@kbn/cases-plugin/common';
import { Case, CaseSeverity, CaseStatuses } from '@kbn/cases-plugin/common/types/domain';
import type { CasePostRequest } from '@kbn/cases-plugin/common/types/api';
import { ConnectorTypes } from '@kbn/cases-plugin/common/types/domain';
import { CasesFindResponse } from '@kbn/cases-plugin/common/types/api';
export interface User {
username: string;
password: string;
description?: string;
roles: string[];
}
export const superUser: User = {
username: 'superuser',
password: 'superuser',
roles: ['superuser'],
};
export const setupAuth = ({
apiCall,
headers,
auth,
}: {
apiCall: SuperTest.Test;
headers: Record<string, unknown>;
auth?: { user: User; space: string | null } | null;
}): SuperTest.Test => {
if (!Object.hasOwn(headers, 'Cookie') && auth != null) {
return apiCall.auth(auth.user.username, auth.user.password);
}
return apiCall;
};
export const getSpaceUrlPrefix = (spaceId: string | undefined | null) => {
return spaceId && spaceId !== 'default' ? `/s/${spaceId}` : ``;
};
export const deleteAllCaseItems = async (es: Client) => {
await Promise.all([
deleteCasesByESQuery(es),
deleteCasesUserActions(es),
deleteComments(es),
deleteConfiguration(es),
deleteMappings(es),
]);
};
export const deleteCasesUserActions = async (es: Client): Promise<void> => {
await es.deleteByQuery({
index: ALERTING_CASES_SAVED_OBJECT_INDEX,
q: 'type:cases-user-actions',
wait_for_completion: true,
refresh: true,
body: {},
conflicts: 'proceed',
});
};
export const deleteCasesByESQuery = async (es: Client): Promise<void> => {
await es.deleteByQuery({
index: ALERTING_CASES_SAVED_OBJECT_INDEX,
q: 'type:cases',
wait_for_completion: true,
refresh: true,
body: {},
conflicts: 'proceed',
});
};
export const deleteComments = async (es: Client): Promise<void> => {
await es.deleteByQuery({
index: ALERTING_CASES_SAVED_OBJECT_INDEX,
q: 'type:cases-comments',
wait_for_completion: true,
refresh: true,
body: {},
conflicts: 'proceed',
});
};
export const deleteConfiguration = async (es: Client): Promise<void> => {
await es.deleteByQuery({
index: ALERTING_CASES_SAVED_OBJECT_INDEX,
q: 'type:cases-configure',
wait_for_completion: true,
refresh: true,
body: {},
conflicts: 'proceed',
});
};
export const deleteMappings = async (es: Client): Promise<void> => {
await es.deleteByQuery({
index: ALERTING_CASES_SAVED_OBJECT_INDEX,
q: 'type:cases-connector-mappings',
wait_for_completion: true,
refresh: true,
body: {},
conflicts: 'proceed',
});
};
export const defaultUser = { email: null, full_name: null, username: 'elastic' };
/**
* A null filled user will occur when the security plugin is disabled
*/
export const nullUser = { email: null, full_name: null, username: null };
export const postCaseReq: CasePostRequest = {
description: 'This is a brand new case of a bad meanie defacing data',
title: 'Super Bad Observability Issue',
tags: ['defacement'],
severity: CaseSeverity.LOW,
connector: {
id: 'none',
name: 'none',
type: ConnectorTypes.none,
fields: null,
},
settings: {
syncAlerts: true,
},
owner: 'observability',
assignees: [],
};
/**
* Return a request for creating a case.
*/
export const getPostCaseRequest = (req?: Partial<CasePostRequest>): CasePostRequest => ({
...postCaseReq,
...req,
});
export const postCaseResp = (
id?: string | null,
req: CasePostRequest = postCaseReq
): Partial<Case> => ({
...req,
...(id != null ? { id } : {}),
comments: [],
duration: null,
severity: req.severity ?? CaseSeverity.LOW,
totalAlerts: 0,
totalComment: 0,
closed_by: null,
created_by: defaultUser,
external_service: null,
status: CaseStatuses.open,
updated_by: null,
category: null,
});
const findCommon = {
page: 1,
per_page: 20,
total: 0,
count_open_cases: 0,
count_closed_cases: 0,
count_in_progress_cases: 0,
};
export const findCasesResp: CasesFindResponse = {
...findCommon,
cases: [],
};
export const createCase = async (
supertest: SuperTest.SuperTest<SuperTest.Test>,
params: CasePostRequest,
expectedHttpCode: number = 200,
auth: { user: User; space: string | null } | null = { user: superUser, space: null },
headers: Record<string, unknown> = {}
): Promise<Case> => {
const apiCall = supertest.post(`${CASES_URL}`);
setupAuth({ apiCall, headers, auth });
const response = await apiCall
.set('kbn-xsrf', 'foo')
.set('x-elastic-internal-origin', 'foo')
.set(headers)
.send(params)
.expect(expectedHttpCode);
return response.body;
};
export const findCases = 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)}${CASES_URL}/_find`)
.auth(auth.user.username, auth.user.password)
.query({ sortOrder: 'asc', ...query })
.set('kbn-xsrf', 'foo')
.set('x-elastic-internal-origin', 'foo')
.send()
.expect(expectedHttpCode);
return res;
};
export const getCase = async ({
supertest,
caseId,
includeComments = false,
expectedHttpCode = 200,
auth = { user: superUser, space: null },
}: {
supertest: SuperTest.SuperTest<SuperTest.Test>;
caseId: string;
includeComments?: boolean;
expectedHttpCode?: number;
auth?: { user: User; space: string | null };
}): Promise<Case> => {
const { body: theCase } = await supertest
.get(
`${getSpaceUrlPrefix(auth?.space)}${CASES_URL}/${caseId}?includeComments=${includeComments}`
)
.set('kbn-xsrf', 'foo')
.set('x-elastic-internal-origin', 'foo')
.auth(auth.user.username, auth.user.password)
.expect(expectedHttpCode);
return theCase;
};

View file

@ -0,0 +1,49 @@
/*
* 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 { Case, Attachment } from '@kbn/cases-plugin/common/types/domain';
import { omit } from 'lodash';
interface CommonSavedObjectAttributes {
id?: string | null;
created_at?: string | null;
updated_at?: string | null;
version?: string | null;
[key: string]: unknown;
}
const savedObjectCommonAttributes = ['created_at', 'updated_at', 'version', 'id'];
export const removeServerGeneratedPropertiesFromObject = <T extends object, K extends keyof T>(
object: T,
keys: K[]
): Omit<T, K> => {
return omit<T, K>(object, keys);
};
export const removeServerGeneratedPropertiesFromSavedObject = <
T extends CommonSavedObjectAttributes
>(
attributes: T,
keys: Array<keyof T> = []
): Omit<T, typeof savedObjectCommonAttributes[number] | typeof keys[number]> => {
return removeServerGeneratedPropertiesFromObject(attributes, [
...savedObjectCommonAttributes,
...keys,
]);
};
export const removeServerGeneratedPropertiesFromCase = (theCase: Case): Partial<Case> => {
return removeServerGeneratedPropertiesFromSavedObject<Case>(theCase, ['closed_at']);
};
export const removeServerGeneratedPropertiesFromComments = (
comments: Attachment[] | undefined
): Array<Partial<Attachment>> | undefined => {
return comments?.map((comment) => {
return removeServerGeneratedPropertiesFromSavedObject<Attachment>(comment, []);
});
};

View file

@ -0,0 +1,64 @@
/*
* 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 { ConnectorTypes } from '@kbn/cases-plugin/common/types/domain';
import { FtrProviderContext } from '../../../ftr_provider_context';
import { deleteCasesByESQuery, createCase, getPostCaseRequest } from './helpers/api';
export default ({ getService }: FtrProviderContext): void => {
const es = getService('es');
const supertest = getService('supertest');
describe('post_case', () => {
afterEach(async () => {
await deleteCasesByESQuery(es);
});
it('should create a case', async () => {
expect(
await createCase(
supertest,
getPostCaseRequest({
connector: {
id: '123',
name: 'Jira',
type: ConnectorTypes.jira,
fields: { issueType: 'Task', priority: 'High', parent: null },
},
}),
200
)
);
});
it('should throw 403 when create a case with securitySolution as owner', async () => {
expect(
await createCase(
supertest,
getPostCaseRequest({
owner: 'securitySolution',
}),
403
)
);
});
it('should throw 403 when create a case with cases as owner', async () => {
expect(
await createCase(
supertest,
getPostCaseRequest({
owner: 'cases',
}),
403
)
);
});
});
};

View file

@ -17,5 +17,8 @@ export default function ({ loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./threshold_rule/documents_count_fired'));
loadTestFile(require.resolve('./threshold_rule/custom_eq_avg_bytes_fired'));
loadTestFile(require.resolve('./threshold_rule/group_by_fired'));
loadTestFile(require.resolve('./cases/post_case'));
loadTestFile(require.resolve('./cases/find_cases'));
loadTestFile(require.resolve('./cases/get_case'));
});
}

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { CASES_URL } from '@kbn/cases-plugin/common/constants';
import { FtrProviderContext } from '../../../ftr_provider_context';
export default ({ getService }: FtrProviderContext): void => {
const supertest = getService('supertest');
describe('find_cases', () => {
it('403 when calling find cases API', async () => {
await supertest
.get(`${CASES_URL}/_find`)
.set('kbn-xsrf', 'foo')
.set('x-elastic-internal-origin', 'foo')
.expect(403);
});
});
};

View file

@ -0,0 +1,43 @@
/*
* 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 { CASES_URL } from '@kbn/cases-plugin/common/constants';
import { CaseSeverity } from '@kbn/cases-plugin/common/types/domain';
import { ConnectorTypes } from '@kbn/cases-plugin/common/types/domain';
import { FtrProviderContext } from '../../../ftr_provider_context';
export default ({ getService }: FtrProviderContext): void => {
const supertest = getService('supertest');
describe('post_case', () => {
it('403 when trying to create case', async () => {
await supertest
.post(CASES_URL)
.set('kbn-xsrf', 'foo')
.set('x-elastic-internal-origin', 'foo')
.send({
description: 'This is a brand new case of a bad meanie defacing data',
title: 'Super Bad Observability Issue',
tags: ['defacement'],
severity: CaseSeverity.LOW,
connector: {
id: 'none',
name: 'none',
type: ConnectorTypes.none,
fields: null,
},
settings: {
syncAlerts: true,
},
owner: 'cases',
assignees: [],
})
.expect(403);
});
});
};

View file

@ -10,5 +10,7 @@ import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ loadTestFile }: FtrProviderContext) {
describe('serverless search API', function () {
loadTestFile(require.resolve('./snapshot_telemetry'));
loadTestFile(require.resolve('./cases/post_case'));
loadTestFile(require.resolve('./cases/find_cases'));
});
}

View file

@ -0,0 +1,60 @@
/*
* 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 '../../../ftr_provider_context';
import {
findCases,
createCase,
deleteAllCaseItems,
findCasesResp,
postCaseReq,
} from './helpers/api';
export default ({ getService }: FtrProviderContext): void => {
const supertest = getService('supertest');
const es = getService('es');
describe('find_cases', () => {
describe('basic tests', () => {
afterEach(async () => {
await deleteAllCaseItems(es);
});
it('should return empty response', async () => {
const cases = await findCases({ supertest });
expect(cases).to.eql(findCasesResp);
});
it('should return cases', async () => {
const a = await createCase(supertest, postCaseReq);
const b = await createCase(supertest, postCaseReq);
const c = await createCase(supertest, postCaseReq);
const cases = await findCases({ supertest });
expect(cases).to.eql({
...findCasesResp,
total: 3,
cases: [a, b, c],
count_open_cases: 3,
});
});
it('returns empty response when trying to find cases with owner as cases', async () => {
const cases = await findCases({ supertest, query: { owner: 'cases' } });
expect(cases).to.eql(findCasesResp);
});
it('returns empty response when trying to find cases with owner as observability', async () => {
const cases = await findCases({ supertest, query: { owner: 'observability' } });
expect(cases).to.eql(findCasesResp);
});
});
});
};

View file

@ -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 expect from '@kbn/expect';
import { FtrProviderContext } from '../../../ftr_provider_context';
import {
getCase,
createCase,
deleteCasesByESQuery,
getPostCaseRequest,
postCaseResp,
} from './helpers/api';
import { removeServerGeneratedPropertiesFromCase } from './helpers/omit';
export default ({ getService }: FtrProviderContext): void => {
const supertest = getService('supertest');
const es = getService('es');
describe('get_case', () => {
afterEach(async () => {
await deleteCasesByESQuery(es);
});
it('should return a case', async () => {
const postedCase = await createCase(supertest, getPostCaseRequest());
const theCase = await getCase({ supertest, caseId: postedCase.id, includeComments: true });
const data = removeServerGeneratedPropertiesFromCase(theCase);
expect(data).to.eql(postCaseResp());
expect(data.comments?.length).to.eql(0);
});
});
};

View file

@ -0,0 +1,247 @@
/*
* 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 { Client } from '@elastic/elasticsearch';
import type SuperTest from 'supertest';
import { ALERTING_CASES_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server/src/saved_objects_index_pattern';
import { CASES_URL } from '@kbn/cases-plugin/common';
import { Case, CaseSeverity, CaseStatuses } from '@kbn/cases-plugin/common/types/domain';
import type { CasePostRequest } from '@kbn/cases-plugin/common/types/api';
import { ConnectorTypes } from '@kbn/cases-plugin/common/types/domain';
import { CasesFindResponse } from '@kbn/cases-plugin/common/types/api';
export interface User {
username: string;
password: string;
description?: string;
roles: string[];
}
export const superUser: User = {
username: 'superuser',
password: 'superuser',
roles: ['superuser'],
};
export const setupAuth = ({
apiCall,
headers,
auth,
}: {
apiCall: SuperTest.Test;
headers: Record<string, unknown>;
auth?: { user: User; space: string | null } | null;
}): SuperTest.Test => {
if (!Object.hasOwn(headers, 'Cookie') && auth != null) {
return apiCall.auth(auth.user.username, auth.user.password);
}
return apiCall;
};
export const getSpaceUrlPrefix = (spaceId: string | undefined | null) => {
return spaceId && spaceId !== 'default' ? `/s/${spaceId}` : ``;
};
export const deleteAllCaseItems = async (es: Client) => {
await Promise.all([
deleteCasesByESQuery(es),
deleteCasesUserActions(es),
deleteComments(es),
deleteConfiguration(es),
deleteMappings(es),
]);
};
export const deleteCasesUserActions = async (es: Client): Promise<void> => {
await es.deleteByQuery({
index: ALERTING_CASES_SAVED_OBJECT_INDEX,
q: 'type:cases-user-actions',
wait_for_completion: true,
refresh: true,
body: {},
conflicts: 'proceed',
});
};
export const deleteCasesByESQuery = async (es: Client): Promise<void> => {
await es.deleteByQuery({
index: ALERTING_CASES_SAVED_OBJECT_INDEX,
q: 'type:cases',
wait_for_completion: true,
refresh: true,
body: {},
conflicts: 'proceed',
});
};
export const deleteComments = async (es: Client): Promise<void> => {
await es.deleteByQuery({
index: ALERTING_CASES_SAVED_OBJECT_INDEX,
q: 'type:cases-comments',
wait_for_completion: true,
refresh: true,
body: {},
conflicts: 'proceed',
});
};
export const deleteConfiguration = async (es: Client): Promise<void> => {
await es.deleteByQuery({
index: ALERTING_CASES_SAVED_OBJECT_INDEX,
q: 'type:cases-configure',
wait_for_completion: true,
refresh: true,
body: {},
conflicts: 'proceed',
});
};
export const deleteMappings = async (es: Client): Promise<void> => {
await es.deleteByQuery({
index: ALERTING_CASES_SAVED_OBJECT_INDEX,
q: 'type:cases-connector-mappings',
wait_for_completion: true,
refresh: true,
body: {},
conflicts: 'proceed',
});
};
export const defaultUser = { email: null, full_name: null, username: 'elastic' };
/**
* A null filled user will occur when the security plugin is disabled
*/
export const nullUser = { email: null, full_name: null, username: null };
export const postCaseReq: CasePostRequest = {
description: 'This is a brand new case of a bad meanie defacing data',
title: 'Super Bad Observability Issue',
tags: ['defacement'],
severity: CaseSeverity.LOW,
connector: {
id: 'none',
name: 'none',
type: ConnectorTypes.none,
fields: null,
},
settings: {
syncAlerts: true,
},
owner: 'securitySolution',
assignees: [],
};
/**
* Return a request for creating a case.
*/
export const getPostCaseRequest = (req?: Partial<CasePostRequest>): CasePostRequest => ({
...postCaseReq,
...req,
});
export const postCaseResp = (
id?: string | null,
req: CasePostRequest = postCaseReq
): Partial<Case> => ({
...req,
...(id != null ? { id } : {}),
comments: [],
duration: null,
severity: req.severity ?? CaseSeverity.LOW,
totalAlerts: 0,
totalComment: 0,
closed_by: null,
created_by: defaultUser,
external_service: null,
status: CaseStatuses.open,
updated_by: null,
category: null,
});
const findCommon = {
page: 1,
per_page: 20,
total: 0,
count_open_cases: 0,
count_closed_cases: 0,
count_in_progress_cases: 0,
};
export const findCasesResp: CasesFindResponse = {
...findCommon,
cases: [],
};
export const createCase = async (
supertest: SuperTest.SuperTest<SuperTest.Test>,
params: CasePostRequest,
expectedHttpCode: number = 200,
auth: { user: User; space: string | null } | null = { user: superUser, space: null },
headers: Record<string, unknown> = {}
): Promise<Case> => {
const apiCall = supertest.post(`${CASES_URL}`);
setupAuth({ apiCall, headers, auth });
const { body: theCase } = await apiCall
.set('kbn-xsrf', 'foo')
.set('x-elastic-internal-origin', 'foo')
.set(headers)
.send(params)
.expect(expectedHttpCode);
return theCase;
};
export const findCases = 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)}${CASES_URL}/_find`)
.auth(auth.user.username, auth.user.password)
.query({ sortOrder: 'asc', ...query })
.set('kbn-xsrf', 'foo')
.set('x-elastic-internal-origin', 'foo')
.send()
.expect(expectedHttpCode);
return res;
};
export const getCase = async ({
supertest,
caseId,
includeComments = false,
expectedHttpCode = 200,
auth = { user: superUser, space: null },
}: {
supertest: SuperTest.SuperTest<SuperTest.Test>;
caseId: string;
includeComments?: boolean;
expectedHttpCode?: number;
auth?: { user: User; space: string | null };
}): Promise<Case> => {
const { body: theCase } = await supertest
.get(
`${getSpaceUrlPrefix(auth?.space)}${CASES_URL}/${caseId}?includeComments=${includeComments}`
)
.set('kbn-xsrf', 'foo')
.set('x-elastic-internal-origin', 'foo')
.auth(auth.user.username, auth.user.password)
.expect(expectedHttpCode);
return theCase;
};

View file

@ -0,0 +1,49 @@
/*
* 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 { Case, Attachment } from '@kbn/cases-plugin/common/types/domain';
import { omit } from 'lodash';
interface CommonSavedObjectAttributes {
id?: string | null;
created_at?: string | null;
updated_at?: string | null;
version?: string | null;
[key: string]: unknown;
}
const savedObjectCommonAttributes = ['created_at', 'updated_at', 'version', 'id'];
export const removeServerGeneratedPropertiesFromObject = <T extends object, K extends keyof T>(
object: T,
keys: K[]
): Omit<T, K> => {
return omit<T, K>(object, keys);
};
export const removeServerGeneratedPropertiesFromSavedObject = <
T extends CommonSavedObjectAttributes
>(
attributes: T,
keys: Array<keyof T> = []
): Omit<T, typeof savedObjectCommonAttributes[number] | typeof keys[number]> => {
return removeServerGeneratedPropertiesFromObject(attributes, [
...savedObjectCommonAttributes,
...keys,
]);
};
export const removeServerGeneratedPropertiesFromCase = (theCase: Case): Partial<Case> => {
return removeServerGeneratedPropertiesFromSavedObject<Case>(theCase, ['closed_at']);
};
export const removeServerGeneratedPropertiesFromComments = (
comments: Attachment[] | undefined
): Array<Partial<Attachment>> | undefined => {
return comments?.map((comment) => {
return removeServerGeneratedPropertiesFromSavedObject<Attachment>(comment, []);
});
};

View file

@ -0,0 +1,77 @@
/*
* 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 { ConnectorTypes } from '@kbn/cases-plugin/common/types/domain';
import { FtrProviderContext } from '../../../ftr_provider_context';
import { deleteCasesByESQuery, createCase, getPostCaseRequest, postCaseResp } from './helpers/api';
import { removeServerGeneratedPropertiesFromCase } from './helpers/omit';
export default ({ getService }: FtrProviderContext): void => {
const es = getService('es');
const supertest = getService('supertest');
describe('post_case', () => {
afterEach(async () => {
await deleteCasesByESQuery(es);
});
it('should create a case', async () => {
const postedCase = await createCase(
supertest,
getPostCaseRequest({
connector: {
id: '123',
name: 'Jira',
type: ConnectorTypes.jira,
fields: { issueType: 'Task', priority: 'High', parent: null },
},
})
);
const data = removeServerGeneratedPropertiesFromCase(postedCase);
expect(data).to.eql(
postCaseResp(
null,
getPostCaseRequest({
connector: {
id: '123',
name: 'Jira',
type: ConnectorTypes.jira,
fields: { issueType: 'Task', priority: 'High', parent: null },
},
})
)
);
});
it('should throw 403 when trying to create a case with observability as owner', async () => {
expect(
await createCase(
supertest,
getPostCaseRequest({
owner: 'observability',
}),
403
)
);
});
it('should throw 403 when trying to create a case with cases as owner', async () => {
expect(
await createCase(
supertest,
getPostCaseRequest({
owner: 'cases',
}),
403
)
);
});
});
};

View file

@ -11,5 +11,8 @@ export default function ({ loadTestFile }: FtrProviderContext) {
describe('serverless security API', function () {
loadTestFile(require.resolve('./fleet'));
loadTestFile(require.resolve('./snapshot_telemetry'));
loadTestFile(require.resolve('./cases/post_case'));
loadTestFile(require.resolve('./cases/find_cases'));
loadTestFile(require.resolve('./cases/get_case'));
});
}

View file

@ -86,5 +86,22 @@ export default function ({ getPageObject, getService }: FtrProviderContext) {
await expect(await browser.getCurrentUrl()).contain('/app/discover#/p/log-explorer');
});
it('shows cases in sidebar navigation', async () => {
await svlCommonNavigation.expectExists();
await svlCommonNavigation.sidenav.expectLinkExists({
deepLinkId: 'observability-overview:cases',
});
});
it('navigates to cases app', async () => {
await svlCommonNavigation.sidenav.clickLink({ deepLinkId: 'observability-overview:cases' });
await svlCommonNavigation.sidenav.expectLinkActive({
deepLinkId: 'observability-overview:cases',
});
expect(await browser.getCurrentUrl()).contain('/app/observability/cases');
});
});
}

View file

@ -74,5 +74,18 @@ export default function ({ getPageObject, getService }: FtrProviderContext) {
await expect(await browser.getCurrentUrl()).contain('/app/discover');
});
it('does not show cases in sidebar navigation', async () => {
await svlSearchLandingPage.assertSvlSearchSideNavExists();
expect(await testSubjects.missingOrFail('cases'));
});
it('does not navigate to cases app', async () => {
await svlCommonNavigation.sidenav.clickLink({ deepLinkId: 'discover' });
expect(await browser.getCurrentUrl()).not.contain('/app/management/cases');
await testSubjects.missingOrFail('cases-all-title');
});
});
}

View file

@ -43,5 +43,19 @@ export default function ({ getPageObject, getService }: FtrProviderContext) {
await expect(await browser.getCurrentUrl()).contain('app/security/dashboards');
});
it('shows cases in sidebar navigation', async () => {
await svlSecLandingPage.assertSvlSecSideNavExists();
await svlCommonNavigation.expectExists();
expect(await testSubjects.existOrFail('solutionSideNavItemLink-cases'));
});
it('navigates to cases app', async () => {
await testSubjects.click('solutionSideNavItemLink-cases');
expect(await browser.getCurrentUrl()).contain('/app/security/cases');
await testSubjects.existOrFail('cases-all-title');
});
});
}

View file

@ -46,5 +46,6 @@
"@kbn/test-subj-selector",
"@kbn/core-http-common",
"@kbn/data-views-plugin",
"@kbn/core-saved-objects-server",
]
}