[Cases][ResponseOps] Add support for deletion sub feature privilege (#135487)

* Starting conversion to permissions from userCanCrud

* Migrating userCanCrud to context

* Fixing tests

* Fix type error

* Removing missed userCanCrud

* Fixing tests and addressing permissions.all feedback

* Fixing test

* Adding deletion sub feature priv

* Fixing type errors

* Fixing tests and adding more granular permissions

* Trying to get plugin tests to work

* Removing unnecessary tests

* First pass at fixing tests

* Moving createUICapabilities to a normal function

* Adding more tests for permissions

* Fixing tests

* Fixing and adding more tests

* Addressing feedback and fixing tests

* Reverting permissions.all changes except delete

* Revert "Reverting permissions.all changes except delete"

This reverts commit 609c150b7d.

* Fixing test

* Adjusting permissions for add to new or existing case

* Switching a few all permissions to create and read

* check permisions inside of actions menu

* Addressing initial feedback

* Adding functional tests for deletion

* Changing deletion text

* Addressing feedback and fixing tests

* Fixing deeplinks to allow create when no delete

* Addressing feedback

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Jonathan Buttner 2022-07-18 12:15:09 -04:00 committed by GitHub
parent 99a01902ca
commit 0f3e46749b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
126 changed files with 2909 additions and 1063 deletions

1
.github/CODEOWNERS vendored
View file

@ -372,6 +372,7 @@
/x-pack/test/cases_api_integration/ @elastic/response-ops
/x-pack/test/functional/services/cases/ @elastic/response-ops
/x-pack/test/functional_with_es_ssl/apps/cases/ @elastic/response-ops
/x-pack/test/api_integration/apis/cases @elastic/response-ops
# Enterprise Search
/x-pack/plugins/enterprise_search @elastic/enterprise-search-frontend

View file

@ -106,6 +106,7 @@ export const MAX_ALERTS_PER_CASE = 1000 as const;
*/
export const SECURITY_SOLUTION_OWNER = 'securitySolution' as const;
export const OBSERVABILITY_OWNER = 'observability' as const;
export const GENERAL_CASES_OWNER = APP_ID;
export const OWNER_INFO = {
[SECURITY_SOLUTION_OWNER]: {
@ -116,6 +117,10 @@ export const OWNER_INFO = {
label: 'Observability',
iconType: 'logoObservability',
},
[GENERAL_CASES_OWNER]: {
label: 'Stack',
iconType: 'casesApp',
},
} as const;
/**
@ -150,3 +155,12 @@ export const CASES_TELEMETRY_TASK_NAME = 'cases-telemetry-task';
*/
export const CASE_TELEMETRY_SAVED_OBJECT = 'cases-telemetry';
export const CASE_TELEMETRY_SAVED_OBJECT_ID = 'cases-telemetry';
/**
* Cases UI Capabilities
*/
export const CREATE_CASES_CAPABILITY = 'create_cases' as const;
export const READ_CASES_CAPABILITY = 'read_cases' as const;
export const UPDATE_CASES_CAPABILITY = 'update_cases' as const;
export const DELETE_CASES_CAPABILITY = 'delete_cases' as const;
export const PUSH_CASES_CAPABILITY = 'push_cases' as const;

View file

@ -15,12 +15,27 @@
// For example, constants below could eventually be in a "kbn-cases-constants" instead.
// See: https://docs.elastic.dev/kibana-dev-docs/key-concepts/platform-intro#public-plugin-api
export { CASES_URL, SECURITY_SOLUTION_OWNER } from './constants';
export {
CASES_URL,
SECURITY_SOLUTION_OWNER,
CREATE_CASES_CAPABILITY,
DELETE_CASES_CAPABILITY,
PUSH_CASES_CAPABILITY,
READ_CASES_CAPABILITY,
UPDATE_CASES_CAPABILITY,
} from './constants';
export { CommentType, CaseStatuses, getCasesFromAlertsUrl, throwErrors } from './api';
export type { Case, Ecs, CasesFeatures, CaseViewRefreshPropInterface } from './ui/types';
export type {
Case,
Ecs,
CasesFeatures,
CaseViewRefreshPropInterface,
CasesPermissions,
} from './ui/types';
export { StatusAll } from './ui/types';
export { getCreateConnectorUrl, getAllConnectorsUrl } from './utils/connectors_api';
export { createUICapabilities } from './utils/capabilities';

View file

@ -6,6 +6,12 @@
*/
import type { SavedObjectsResolveResponse } from '@kbn/core/public';
import {
CREATE_CASES_CAPABILITY,
DELETE_CASES_CAPABILITY,
READ_CASES_CAPABILITY,
UPDATE_CASES_CAPABILITY,
} from '..';
import {
CasePatchRequest,
CaseStatuses,
@ -24,6 +30,7 @@ import {
CommentResponseExternalReferenceType,
CommentResponseTypePersistableState,
} from '../api';
import { PUSH_CASES_CAPABILITY } from '../constants';
import { SnakeToCamelCase } from '../types';
type DeepRequired<T> = { [K in keyof T]: DeepRequired<T[K]> } & Required<T>;
@ -229,3 +236,20 @@ export interface Ecs {
export type CaseActionConnector = ActionConnector;
export type UseFetchAlertData = (alertIds: string[]) => [boolean, Record<string, unknown>];
export interface CasesPermissions {
all: boolean;
create: boolean;
read: boolean;
update: boolean;
delete: boolean;
push: boolean;
}
export interface CasesCapabilities {
[CREATE_CASES_CAPABILITY]: boolean;
[READ_CASES_CAPABILITY]: boolean;
[UPDATE_CASES_CAPABILITY]: boolean;
[DELETE_CASES_CAPABILITY]: boolean;
[PUSH_CASES_CAPABILITY]: boolean;
}

View file

@ -0,0 +1,29 @@
/*
* 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 {
CREATE_CASES_CAPABILITY,
DELETE_CASES_CAPABILITY,
PUSH_CASES_CAPABILITY,
READ_CASES_CAPABILITY,
UPDATE_CASES_CAPABILITY,
} from '../constants';
/**
* Return the UI capabilities for each type of operation. These strings must match the values defined in the UI
* here: x-pack/plugins/cases/public/client/helpers/capabilities.ts
*/
export const createUICapabilities = () => ({
all: [
CREATE_CASES_CAPABILITY,
READ_CASES_CAPABILITY,
UPDATE_CASES_CAPABILITY,
PUSH_CASES_CAPABILITY,
] as const,
read: [READ_CASES_CAPABILITY] as const,
delete: [DELETE_CASES_CAPABILITY] as const,
});

View file

@ -6,3 +6,4 @@
*/
export * from './connectors_api';
export * from './capabilities';

View file

@ -6,124 +6,91 @@
*/
import type { ApplicationStart } from '@kbn/core/public';
import {
allCasesCapabilities,
allCasesPermissions,
noCasesCapabilities,
noCasesPermissions,
readCasesCapabilities,
readCasesPermissions,
writeCasesCapabilities,
writeCasesPermissions,
} from '../../common/mock';
import { canUseCases } from './can_use_cases';
type CasesCapabilities = Pick<
ApplicationStart['capabilities'],
'securitySolutionCases' | 'observabilityCases'
'securitySolutionCases' | 'observabilityCases' | 'generalCases'
>;
const hasAll: CasesCapabilities = {
securitySolutionCases: {
crud_cases: true,
read_cases: true,
},
observabilityCases: {
crud_cases: true,
read_cases: true,
},
securitySolutionCases: allCasesCapabilities(),
observabilityCases: allCasesCapabilities(),
generalCases: allCasesCapabilities(),
};
const hasNone: CasesCapabilities = {
securitySolutionCases: {
crud_cases: false,
read_cases: false,
},
observabilityCases: {
crud_cases: false,
read_cases: false,
},
securitySolutionCases: noCasesCapabilities(),
observabilityCases: noCasesCapabilities(),
generalCases: noCasesCapabilities(),
};
const hasSecurity = {
securitySolutionCases: {
crud_cases: true,
read_cases: true,
},
observabilityCases: {
crud_cases: false,
read_cases: false,
},
const hasSecurity: CasesCapabilities = {
securitySolutionCases: allCasesCapabilities(),
observabilityCases: noCasesCapabilities(),
generalCases: noCasesCapabilities(),
};
const hasObservability = {
securitySolutionCases: {
crud_cases: false,
read_cases: false,
},
observabilityCases: {
crud_cases: true,
read_cases: true,
},
const hasObservability: CasesCapabilities = {
securitySolutionCases: noCasesCapabilities(),
observabilityCases: allCasesCapabilities(),
generalCases: noCasesCapabilities(),
};
const hasObservabilityCrudTrue = {
securitySolutionCases: {
crud_cases: false,
read_cases: false,
},
observabilityCases: {
crud_cases: true,
read_cases: false,
},
const hasObservabilityWriteTrue: CasesCapabilities = {
securitySolutionCases: noCasesCapabilities(),
observabilityCases: writeCasesCapabilities(),
generalCases: noCasesCapabilities(),
};
const hasSecurityCrudTrue = {
securitySolutionCases: {
crud_cases: false,
read_cases: false,
},
observabilityCases: {
crud_cases: true,
read_cases: false,
},
const hasSecurityWriteTrue: CasesCapabilities = {
securitySolutionCases: writeCasesCapabilities(),
observabilityCases: noCasesCapabilities(),
generalCases: noCasesCapabilities(),
};
const hasObservabilityReadTrue = {
securitySolutionCases: {
crud_cases: false,
read_cases: false,
},
observabilityCases: {
crud_cases: false,
read_cases: true,
},
const hasObservabilityReadTrue: CasesCapabilities = {
securitySolutionCases: noCasesCapabilities(),
observabilityCases: readCasesCapabilities(),
generalCases: noCasesCapabilities(),
};
const hasSecurityReadTrue = {
securitySolutionCases: {
crud_cases: false,
read_cases: true,
},
observabilityCases: {
crud_cases: false,
read_cases: false,
},
const hasSecurityReadTrue: CasesCapabilities = {
securitySolutionCases: readCasesCapabilities(),
observabilityCases: noCasesCapabilities(),
generalCases: noCasesCapabilities(),
};
const hasSecurityAsCrudAndObservabilityAsRead = {
securitySolutionCases: {
crud_cases: true,
},
observabilityCases: {
read_cases: true,
},
const hasSecurityWriteAndObservabilityRead: CasesCapabilities = {
securitySolutionCases: writeCasesCapabilities(),
observabilityCases: readCasesCapabilities(),
generalCases: noCasesCapabilities(),
};
describe('canUseCases', () => {
it.each([hasAll, hasSecurity, hasObservability, hasSecurityAsCrudAndObservabilityAsRead])(
'returns true for both crud and read, if a user has access to both on any solution',
it.each([hasAll, hasSecurity, hasObservability, hasSecurityWriteAndObservabilityRead])(
'returns true for all permissions, if a user has access to both on any solution',
(capability) => {
const permissions = canUseCases(capability)();
expect(permissions).toStrictEqual({ crud: true, read: true });
expect(permissions).toStrictEqual(allCasesPermissions());
}
);
it.each([hasObservabilityCrudTrue, hasSecurityCrudTrue])(
'returns true for only crud, if a user has access to only crud on any solution',
it.each([hasObservabilityWriteTrue, hasSecurityWriteTrue])(
'returns true for only write, if a user has access to only write on any solution',
(capability) => {
const permissions = canUseCases(capability)();
expect(permissions).toStrictEqual({ crud: true, read: false });
expect(permissions).toStrictEqual(writeCasesPermissions());
}
);
@ -131,15 +98,15 @@ describe('canUseCases', () => {
'returns true for only read, if a user has access to only read on any solution',
(capability) => {
const permissions = canUseCases(capability)();
expect(permissions).toStrictEqual({ crud: false, read: true });
expect(permissions).toStrictEqual(readCasesPermissions());
}
);
it.each([hasNone, {}])(
'returns false for both, if a user has access to no solution',
'returns false for all permissions, if a user has access to no solution',
(capability) => {
const permissions = canUseCases(capability)();
expect(permissions).toStrictEqual({ crud: false, read: false });
expect(permissions).toStrictEqual(noCasesPermissions());
}
);
});

View file

@ -6,9 +6,19 @@
*/
import type { ApplicationStart } from '@kbn/core/public';
import { OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER } from '../../../common/constants';
import {
FEATURE_ID,
GENERAL_CASES_OWNER,
OBSERVABILITY_OWNER,
SECURITY_SOLUTION_OWNER,
} from '../../../common/constants';
import { getUICapabilities } from './capabilities';
import { CasesPermissions } from '../../../common';
export type CasesOwners = typeof SECURITY_SOLUTION_OWNER | typeof OBSERVABILITY_OWNER;
export type CasesOwners =
| typeof SECURITY_SOLUTION_OWNER
| typeof OBSERVABILITY_OWNER
| typeof GENERAL_CASES_OWNER;
/*
* Returns an object denoting the current user's ability to read and crud cases.
@ -16,14 +26,44 @@ export type CasesOwners = typeof SECURITY_SOLUTION_OWNER | typeof OBSERVABILITY_
* then crud or read is set to true.
* Permissions for a specific owners can be found by passing an owner array
*/
export const canUseCases =
(capabilities: Partial<ApplicationStart['capabilities']>) =>
(
owners: CasesOwners[] = [OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER]
): { crud: boolean; read: boolean } => ({
crud:
(capabilities && owners.some((owner) => capabilities[`${owner}Cases`]?.crud_cases)) ?? false,
read:
(capabilities && owners.some((owner) => capabilities[`${owner}Cases`]?.read_cases)) ?? false,
});
owners: CasesOwners[] = [OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER, GENERAL_CASES_OWNER]
): CasesPermissions => {
const aggregatedPermissions = owners.reduce<CasesPermissions>(
(acc, owner) => {
const userCapabilitiesForOwner = getUICapabilities(capabilities[getFeatureID(owner)]);
acc.create = acc.create || userCapabilitiesForOwner.create;
acc.read = acc.read || userCapabilitiesForOwner.read;
acc.update = acc.update || userCapabilitiesForOwner.update;
acc.delete = acc.delete || userCapabilitiesForOwner.delete;
acc.push = acc.push || userCapabilitiesForOwner.push;
const allFromAcc = acc.create && acc.read && acc.update && acc.delete && acc.push;
acc.all = acc.all || userCapabilitiesForOwner.all || allFromAcc;
return acc;
},
{
all: false,
create: false,
read: false,
update: false,
delete: false,
push: false,
}
);
return {
...aggregatedPermissions,
};
};
const getFeatureID = (owner: CasesOwners) => {
if (owner === GENERAL_CASES_OWNER) {
return FEATURE_ID;
}
return `${owner}Cases`;
};

View file

@ -0,0 +1,104 @@
/*
* 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 { getUICapabilities } from './capabilities';
describe('getUICapabilities', () => {
it('returns false for all fields when the feature cannot be found', () => {
expect(getUICapabilities(undefined)).toMatchInlineSnapshot(`
Object {
"all": false,
"create": false,
"delete": false,
"push": false,
"read": false,
"update": false,
}
`);
});
it('returns false for all fields when the capabilities are not passed in', () => {
expect(getUICapabilities()).toMatchInlineSnapshot(`
Object {
"all": false,
"create": false,
"delete": false,
"push": false,
"read": false,
"update": false,
}
`);
});
it('returns true for create when it is set to true in the ui capabilities', () => {
expect(getUICapabilities({ create_cases: true })).toMatchInlineSnapshot(`
Object {
"all": false,
"create": true,
"delete": false,
"push": false,
"read": false,
"update": false,
}
`);
});
it('returns false for all fields when the ui capabilities are false', () => {
expect(
getUICapabilities({
create_cases: false,
read_cases: false,
update_cases: false,
delete_cases: false,
push_cases: false,
})
).toMatchInlineSnapshot(`
Object {
"all": false,
"create": false,
"delete": false,
"push": false,
"read": false,
"update": false,
}
`);
});
it('returns false for all fields when the ui capabilities is an empty object', () => {
expect(getUICapabilities({})).toMatchInlineSnapshot(`
Object {
"all": false,
"create": false,
"delete": false,
"push": false,
"read": false,
"update": false,
}
`);
});
it('returns false for the all field when a single field is false', () => {
expect(
getUICapabilities({
create_cases: false,
read_cases: true,
update_cases: true,
delete_cases: true,
push_cases: true,
})
).toMatchInlineSnapshot(`
Object {
"all": false,
"create": false,
"delete": true,
"push": true,
"read": true,
"update": true,
}
`);
});
});

View file

@ -5,19 +5,31 @@
* 2.0.
*/
export interface CasesPermissions {
all: boolean;
read: boolean;
}
import { CasesPermissions } from '../../../common';
import {
CREATE_CASES_CAPABILITY,
DELETE_CASES_CAPABILITY,
PUSH_CASES_CAPABILITY,
READ_CASES_CAPABILITY,
UPDATE_CASES_CAPABILITY,
} from '../../../common/constants';
export const getUICapabilities = (
featureCapabilities: Partial<Record<string, boolean | Record<string, boolean>>>
featureCapabilities?: Partial<Record<string, boolean | Record<string, boolean>>>
): CasesPermissions => {
const read = !!featureCapabilities?.read_cases;
const all = !!featureCapabilities?.crud_cases;
const create = !!featureCapabilities?.[CREATE_CASES_CAPABILITY];
const read = !!featureCapabilities?.[READ_CASES_CAPABILITY];
const update = !!featureCapabilities?.[UPDATE_CASES_CAPABILITY];
const deletePriv = !!featureCapabilities?.[DELETE_CASES_CAPABILITY];
const push = !!featureCapabilities?.[PUSH_CASES_CAPABILITY];
const all = create && read && update && deletePriv && push;
return {
all,
create,
read,
update,
delete: deletePriv,
push,
};
};

View file

@ -9,7 +9,7 @@ import React from 'react';
import { renderHook } from '@testing-library/react-hooks';
import { useApplicationCapabilities } from './hooks';
import { TestProviders } from '../../mock';
import { allCasesPermissions, TestProviders } from '../../mock';
describe('hooks', () => {
describe('useApplicationCapabilities', () => {
@ -23,7 +23,7 @@ describe('hooks', () => {
expect(result.current).toEqual({
actions: { crud: true, read: true },
generalCases: { all: true, read: true },
generalCases: allCasesPermissions(),
visualize: { crud: true, read: true },
dashboard: { crud: true, read: true },
});

View file

@ -12,13 +12,14 @@ import { i18n } from '@kbn/i18n';
import { AuthenticatedUser } from '@kbn/security-plugin/common/model';
import { NavigateToAppOptions } from '@kbn/core/public';
import { CasesPermissions, getUICapabilities } from '../../../client/helpers/capabilities';
import { getUICapabilities } from '../../../client/helpers/capabilities';
import { convertToCamelCase } from '../../../api/utils';
import {
FEATURE_ID,
DEFAULT_DATE_FORMAT,
DEFAULT_DATE_FORMAT_TZ,
} from '../../../../common/constants';
import { CasesPermissions } from '../../../../common';
import { StartServices } from '../../../types';
import { useUiSetting, useKibana } from './kibana_react';
@ -187,7 +188,11 @@ export const useApplicationCapabilities = (): UseApplicationCapabilities => {
actions: { crud: !!capabilities.actions?.save, read: !!capabilities.actions?.show },
generalCases: {
all: permissions.all,
create: permissions.create,
read: permissions.read,
update: permissions.update,
delete: permissions.delete,
push: permissions.push,
},
visualize: { crud: !!capabilities.visualize?.save, read: !!capabilities.visualize?.show },
dashboard: {
@ -203,7 +208,11 @@ export const useApplicationCapabilities = (): UseApplicationCapabilities => {
capabilities.visualize?.save,
capabilities.visualize?.show,
permissions.all,
permissions.create,
permissions.read,
permissions.update,
permissions.delete,
permissions.push,
]
);
};

View file

@ -53,7 +53,13 @@ export const createStartServicesMock = (): StartServices => {
services.application.capabilities = {
...services.application.capabilities,
actions: { save: true, show: true },
generalCases: { crud_cases: true, read_cases: true },
generalCases: {
create_cases: true,
read_cases: true,
update_cases: true,
delete_cases: true,
push_cases: true,
},
visualize: { save: true, show: true },
dashboard: { show: true, createNew: true },
};

View file

@ -14,7 +14,7 @@ import { render as reactRender, RenderOptions, RenderResult } from '@testing-lib
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { QueryClient, QueryClientProvider } from 'react-query';
import { SECURITY_SOLUTION_OWNER } from '../../../common/constants';
import { CasesFeatures } from '../../../common/ui/types';
import { CasesCapabilities, CasesFeatures, CasesPermissions } from '../../../common/ui/types';
import { CasesProvider } from '../../components/cases_context';
import {
createKibanaContextProviderMock,
@ -23,7 +23,6 @@ import {
import { FieldHook } from '../shared_imports';
import { StartServices } from '../../types';
import { ReleasePhase } from '../../components/types';
import { CasesPermissions } from '../../client/helpers/capabilities';
import { ExternalReferenceAttachmentTypeRegistry } from '../../client/attachment_framework/external_reference_registry';
import { PersistableStateAttachmentTypeRegistry } from '../../client/attachment_framework/persistable_state_registry';
@ -101,19 +100,66 @@ export const testQueryClient = new QueryClient({
},
});
export const buildCasesPermissions = (overrides: Partial<CasesPermissions> = {}) => {
export const allCasesPermissions = () => buildCasesPermissions();
export const noCasesPermissions = () =>
buildCasesPermissions({ read: false, create: false, update: false, delete: false, push: false });
export const readCasesPermissions = () =>
buildCasesPermissions({ read: true, create: false, update: false, delete: false, push: false });
export const noCreateCasesPermissions = () => buildCasesPermissions({ create: false });
export const noUpdateCasesPermissions = () => buildCasesPermissions({ update: false });
export const noPushCasesPermissions = () => buildCasesPermissions({ push: false });
export const noDeleteCasesPermissions = () => buildCasesPermissions({ delete: false });
export const writeCasesPermissions = () => buildCasesPermissions({ read: false });
export const buildCasesPermissions = (overrides: Partial<Omit<CasesPermissions, 'all'>> = {}) => {
const create = overrides.create ?? true;
const read = overrides.read ?? true;
const all = overrides.all ?? true;
const update = overrides.update ?? true;
const deletePermissions = overrides.delete ?? true;
const push = overrides.push ?? true;
const all = create && read && update && deletePermissions && push;
return {
all,
create,
read,
update,
delete: deletePermissions,
push,
};
};
export const allCasesPermissions = () => buildCasesPermissions();
export const noCasesPermissions = () => buildCasesPermissions({ read: false, all: false });
export const readCasesPermissions = () => buildCasesPermissions({ all: false });
export const allCasesCapabilities = () => buildCasesCapabilities();
export const noCasesCapabilities = () =>
buildCasesCapabilities({
create_cases: false,
read_cases: false,
update_cases: false,
delete_cases: false,
push_cases: false,
});
export const readCasesCapabilities = () =>
buildCasesCapabilities({
create_cases: false,
update_cases: false,
delete_cases: false,
push_cases: false,
});
export const writeCasesCapabilities = () => {
return buildCasesCapabilities({
read_cases: false,
});
};
export const buildCasesCapabilities = (overrides?: Partial<CasesCapabilities>) => {
return {
create_cases: overrides?.create_cases ?? true,
read_cases: overrides?.read_cases ?? true,
update_cases: overrides?.update_cases ?? true,
delete_cases: overrides?.delete_cases ?? true,
push_cases: overrides?.push_cases ?? true,
};
};
export const createAppMockRenderer = ({
features,

View file

@ -10,7 +10,7 @@ import { mount } from 'enzyme';
import { waitFor, act } from '@testing-library/react';
import { noop } from 'lodash/fp';
import { TestProviders } from '../../common/mock';
import { noCreateCasesPermissions, TestProviders } from '../../common/mock';
import { CommentRequest, CommentType } from '../../../common/api';
import { SECURITY_SOLUTION_OWNER } from '../../../common/constants';
@ -113,13 +113,13 @@ describe('AddComment ', () => {
).toBeTruthy();
});
it('should hide the component when the user does not have crud permissions', () => {
it('should hide the component when the user does not have create permissions', () => {
useCreateAttachmentsMock.mockImplementation(() => ({
...defaultResponse,
isLoading: true,
}));
const wrapper = mount(
<TestProviders permissions={{ all: false, read: false }}>
<TestProviders permissions={noCreateCasesPermissions()}>
<AddComment {...{ ...addCommentProps }} />
</TestProviders>
);

View file

@ -147,7 +147,7 @@ export const AddComment = React.memo(
return (
<span id="add-comment-permLink">
{isLoading && showLoading && <MySpinner data-test-subj="loading-spinner" size="xl" />}
{permissions.all && (
{permissions.create && (
<Form form={form}>
<UseField
path={fieldName}

View file

@ -13,7 +13,12 @@ import { renderHook } from '@testing-library/react-hooks';
import userEvent from '@testing-library/user-event';
import '../../common/mock/match_media';
import { AppMockRenderer, createAppMockRenderer, TestProviders } from '../../common/mock';
import {
AppMockRenderer,
createAppMockRenderer,
noDeleteCasesPermissions,
TestProviders,
} from '../../common/mock';
import { casesStatus, useGetCasesMockState, mockCase, connectorsMock } from '../../containers/mock';
import { StatusAll } from '../../../common/ui/types';
@ -503,6 +508,20 @@ describe('AllCasesListGeneric', () => {
});
});
it('should not render table utility bar when the user does not have permissions to delete', async () => {
const wrapper = mount(
<TestProviders permissions={noDeleteCasesPermissions()}>
<AllCasesList isSelectorView={true} />
</TestProviders>
);
await waitFor(() => {
expect(wrapper.find('[data-test-subj="case-table-selected-case-count"]').exists()).toBe(
false
);
expect(wrapper.find('[data-test-subj="case-table-bulk-actions"]').exists()).toBe(false);
});
});
it('should render metrics when isSelectorView=false', async () => {
const wrapper = mount(
<TestProviders>

View file

@ -185,7 +185,11 @@ export const AllCasesList = React.memo<AllCasesListProps>(
[deselectCases, setFilterOptions, refreshCases, setQueryParams]
);
const showActions = permissions.all && !isSelectorView;
/**
* At the time of changing this from all to delete the only bulk action we have is to delete. When we add more
* actions we'll need to revisit this to allow more granular checks around the bulk actions.
*/
const showActions = permissions.delete && !isSelectorView;
const columns = useCasesColumns({
filterStatus: filterOptions.status ?? StatusAll,

View file

@ -319,7 +319,7 @@ export const useCasesColumns = ({
return (
<StatusContextMenu
currentStatus={theCase.status}
disabled={!permissions.all || isLoadingUpdateCase}
disabled={!permissions.update || isLoadingUpdateCase}
onStatusChanged={(status) =>
handleDispatchUpdate({
updateKey: 'status',
@ -372,7 +372,7 @@ export const useCasesColumns = ({
},
]
: []),
...(permissions.all && !isSelectorView
...(permissions.delete && !isSelectorView
? [
{
name: (

View file

@ -0,0 +1,56 @@
/*
* 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 React from 'react';
import { AppMockRenderer, buildCasesPermissions, createAppMockRenderer } from '../../common/mock';
import { CasesTableHeader } from './header';
describe('CasesTableHeader', () => {
let appMockRender: AppMockRenderer;
beforeEach(() => {
jest.clearAllMocks();
appMockRender = createAppMockRenderer();
});
it('displays the create new case button when the user has create privileges', () => {
appMockRender = createAppMockRenderer({
permissions: buildCasesPermissions({ update: false, create: true }),
});
const result = appMockRender.render(<CasesTableHeader actionsErrors={[]} />);
expect(result.getByTestId('createNewCaseBtn')).toBeInTheDocument();
});
it('does not display the create new case button when the user does not have create privileges', () => {
appMockRender = createAppMockRenderer({
permissions: buildCasesPermissions({ create: false }),
});
const result = appMockRender.render(<CasesTableHeader actionsErrors={[]} />);
expect(result.queryByTestId('createNewCaseBtn')).not.toBeInTheDocument();
});
it('displays the configure button when the user has update privileges', () => {
appMockRender = createAppMockRenderer({
permissions: buildCasesPermissions({ create: false, update: true }),
});
const result = appMockRender.render(<CasesTableHeader actionsErrors={[]} />);
expect(result.getByTestId('configure-case-button')).toBeInTheDocument();
});
it('does not display the configure button when the user does not have update privileges', () => {
appMockRender = createAppMockRenderer({
permissions: buildCasesPermissions({ update: false }),
});
const result = appMockRender.render(<CasesTableHeader actionsErrors={[]} />);
expect(result.queryByTestId('configure-case-button')).not.toBeInTheDocument();
});
});

View file

@ -6,12 +6,11 @@
*/
import React, { FunctionComponent } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { EuiFlexGroup } from '@elastic/eui';
import { HeaderPage } from '../header_page';
import * as i18n from './translations';
import { ErrorMessage } from '../use_push_to_service/callout/types';
import { NavButtons } from './nav_buttons';
import { useCasesContext } from '../cases_context/use_cases_context';
interface OwnProps {
actionsErrors: ErrorMessage[];
@ -20,8 +19,6 @@ interface OwnProps {
type Props = OwnProps;
export const CasesTableHeader: FunctionComponent<Props> = ({ actionsErrors }) => {
const { permissions } = useCasesContext();
return (
<HeaderPage title={i18n.PAGE_TITLE} border data-test-subj="cases-all-title">
<EuiFlexGroup
@ -30,11 +27,7 @@ export const CasesTableHeader: FunctionComponent<Props> = ({ actionsErrors }) =>
wrap={true}
data-test-subj="all-cases-header"
>
{permissions.all ? (
<EuiFlexItem>
<NavButtons actionsErrors={actionsErrors} />
</EuiFlexItem>
) : null}
<NavButtons actionsErrors={actionsErrors} />
</EuiFlexGroup>
</HeaderPage>
);

View file

@ -10,7 +10,12 @@ import { mount } from 'enzyme';
import { waitFor } from '@testing-library/react';
import { AllCases } from '.';
import { TestProviders } from '../../common/mock';
import {
AppMockRenderer,
createAppMockRenderer,
noCreateCasesPermissions,
TestProviders,
} from '../../common/mock';
import { useGetReporters } from '../../containers/use_get_reporters';
import { useGetActionLicense } from '../../containers/use_get_action_license';
import { casesStatus, connectorsMock, useGetCasesMockState } from '../../containers/mock';
@ -79,8 +84,39 @@ describe('AllCases', () => {
useGetCasesMock.mockReturnValue(defaultGetCases);
});
let appMockRender: AppMockRenderer;
beforeEach(() => {
jest.clearAllMocks();
appMockRender = createAppMockRenderer();
});
describe('empty table', () => {
beforeEach(() => {
useGetCasesMock.mockReturnValue({
...defaultGetCases,
data: {
...defaultGetCases.data,
cases: [],
total: 0,
},
});
});
it('should render the create new case link when the user has create privileges', async () => {
const result = appMockRender.render(<AllCases />);
await waitFor(() => {
expect(result.getByTestId('cases-table-add-case')).toBeInTheDocument();
});
});
it('should not render the create new case link when the user does not have create privileges', async () => {
appMockRender = createAppMockRenderer({ permissions: noCreateCasesPermissions() });
const result = appMockRender.render(<AllCases />);
await waitFor(() => {
expect(result.queryByTestId('cases-table-add-case')).not.toBeInTheDocument();
});
});
});
it('should render the stats', async () => {

View file

@ -13,6 +13,7 @@ import * as i18n from './translations';
import { ConfigureCaseButton, LinkButton } from '../links';
import { ErrorMessage } from '../use_push_to_service/callout/types';
import { useCreateCaseNavigation } from '../../common/navigation';
import { useCasesContext } from '../cases_context/use_cases_context';
const ButtonFlexGroup = styled(EuiFlexGroup)`
${({ theme }) => css`
@ -31,6 +32,7 @@ interface OwnProps {
type Props = OwnProps;
export const NavButtons: FunctionComponent<Props> = ({ actionsErrors }) => {
const { permissions } = useCasesContext();
const { getCreateCaseUrl, navigateToCreateCase } = useCreateCaseNavigation();
const navigateToCreateCaseClick = useCallback(
(e) => {
@ -39,29 +41,40 @@ export const NavButtons: FunctionComponent<Props> = ({ actionsErrors }) => {
},
[navigateToCreateCase]
);
if (!permissions.create && !permissions.update) {
return null;
}
return (
<ButtonFlexGroup responsive={false}>
<EuiFlexItem grow={false}>
<ConfigureCaseButton
label={i18n.CONFIGURE_CASES_BUTTON}
isDisabled={!isEmpty(actionsErrors)}
showToolTip={!isEmpty(actionsErrors)}
msgTooltip={!isEmpty(actionsErrors) ? <>{actionsErrors[0].description}</> : <></>}
titleTooltip={!isEmpty(actionsErrors) ? actionsErrors[0].title : ''}
/>
</EuiFlexItem>
<EuiFlexItem>
<LinkButton
fill
onClick={navigateToCreateCaseClick}
href={getCreateCaseUrl()}
iconType="plusInCircle"
data-test-subj="createNewCaseBtn"
>
{i18n.CREATE_CASE_TITLE}
</LinkButton>
</EuiFlexItem>
</ButtonFlexGroup>
<EuiFlexItem>
<ButtonFlexGroup responsive={false}>
{permissions.update && (
<EuiFlexItem grow={false}>
<ConfigureCaseButton
label={i18n.CONFIGURE_CASES_BUTTON}
isDisabled={!isEmpty(actionsErrors)}
showToolTip={!isEmpty(actionsErrors)}
msgTooltip={!isEmpty(actionsErrors) ? <>{actionsErrors[0].description}</> : <></>}
titleTooltip={!isEmpty(actionsErrors) ? actionsErrors[0].title : ''}
/>
</EuiFlexItem>
)}
{permissions.create && (
<EuiFlexItem>
<LinkButton
fill
onClick={navigateToCreateCaseClick}
href={getCreateCaseUrl()}
iconType="plusInCircle"
data-test-subj="createNewCaseBtn"
>
{i18n.CREATE_CASE_TITLE}
</LinkButton>
</EuiFlexItem>
)}
</ButtonFlexGroup>
</EuiFlexItem>
);
};
NavButtons.displayName = 'NavButtons';

View file

@ -11,7 +11,7 @@ import userEvent from '@testing-library/user-event';
import React from 'react';
import AllCasesSelectorModal from '.';
import { Case, CaseStatuses, StatusAll } from '../../../../common';
import { AppMockRenderer, createAppMockRenderer } from '../../../common/mock';
import { allCasesPermissions, AppMockRenderer, createAppMockRenderer } from '../../../common/mock';
import { useCasesToast } from '../../../common/use_cases_toast';
import { alertComment } from '../../../containers/mock';
import { useCreateAttachments } from '../../../containers/use_create_attachments';
@ -64,10 +64,7 @@ describe('use cases add to existing case modal hook', () => {
externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry,
owner: ['test'],
permissions: {
all: true,
read: true,
},
permissions: allCasesPermissions(),
appId: 'test',
appTitle: 'jest',
basePath: '/jest',

View file

@ -109,11 +109,11 @@ export const CasesTable: FunctionComponent<CasesTableProps> = ({
<EuiEmptyPrompt
title={<h3>{i18n.NO_CASES}</h3>}
titleSize="xs"
body={permissions.all ? i18n.NO_CASES_BODY : i18n.NO_CASES_BODY_READ_ONLY}
body={permissions.create ? i18n.NO_CASES_BODY : i18n.NO_CASES_BODY_READ_ONLY}
actions={
permissions.all && (
permissions.create && (
<LinkButton
isDisabled={!permissions.all}
isDisabled={!permissions.create}
fill
size="s"
onClick={navigateToCreateCaseClick}

View file

@ -10,9 +10,14 @@ import React from 'react';
import { MemoryRouterProps } from 'react-router';
import { render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { readCasesPermissions, TestProviders } from '../../common/mock';
import {
noCreateCasesPermissions,
noUpdateCasesPermissions,
readCasesPermissions,
TestProviders,
} from '../../common/mock';
import { CasesRoutes } from './routes';
import { CasesPermissions } from '../../client/helpers/capabilities';
import { CasesPermissions } from '../../../common';
jest.mock('../all_cases', () => ({
AllCases: () => <div>{'All cases'}</div>,
@ -85,8 +90,8 @@ describe('Cases routes', () => {
expect(screen.getByText('Create case')).toBeInTheDocument();
});
it('shows the no privileges page if user is read only', () => {
renderWithRouter(['/cases/create'], readCasesPermissions());
it('shows the no privileges page if the user does not have create privileges', () => {
renderWithRouter(['/cases/create'], noCreateCasesPermissions());
expect(screen.getByText('Privileges required')).toBeInTheDocument();
});
});
@ -97,8 +102,8 @@ describe('Cases routes', () => {
expect(screen.getByText('Configure cases')).toBeInTheDocument();
});
it('shows the no privileges page if user is read only', () => {
renderWithRouter(['/cases/configure'], readCasesPermissions());
it('shows the no privileges page if the user does not have update privileges', () => {
renderWithRouter(['/cases/configure'], noUpdateCasesPermissions());
expect(screen.getByText('Privileges required')).toBeInTheDocument();
});
});

View file

@ -58,7 +58,7 @@ const CasesRoutesComponent: React.FC<CasesRoutesProps> = ({
</Route>
<Route path={getCreateCasePath(basePath)}>
{permissions.all ? (
{permissions.create ? (
<CreateCase
onSuccess={onCreateCaseSuccess}
onCancel={navigateToAllCases}
@ -70,7 +70,7 @@ const CasesRoutesComponent: React.FC<CasesRoutesProps> = ({
</Route>
<Route path={getCasesConfigurePath(basePath)}>
{permissions.all ? (
{permissions.update ? (
<ConfigureCases />
) : (
<NoPrivilegesPage pageName={i18n.CONFIGURE_CASES_PAGE_NAME} />

View file

@ -7,8 +7,13 @@
import { renderHook } from '@testing-library/react-hooks';
import { OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER } from '../../../common/constants';
import { APP_ID, OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER } from '../../../common/constants';
import { useKibana } from '../../common/lib/kibana';
import {
allCasesCapabilities,
noCasesCapabilities,
readCasesCapabilities,
} from '../../common/mock';
import { useAvailableCasesOwners } from './use_available_owners';
jest.mock('../../common/lib/kibana');
@ -16,30 +21,19 @@ jest.mock('../../common/lib/kibana');
const useKibanaMock = useKibana as jest.MockedFunction<typeof useKibana>;
const hasAll = {
securitySolutionCases: {
crud_cases: true,
read_cases: true,
},
observabilityCases: {
crud_cases: true,
read_cases: true,
},
securitySolutionCases: allCasesCapabilities(),
observabilityCases: allCasesCapabilities(),
generalCases: allCasesCapabilities(),
};
const hasSecurityAsCrudAndObservabilityAsRead = {
securitySolutionCases: {
crud_cases: true,
},
observabilityCases: {
read_cases: true,
},
const secAllObsReadGenNone = {
securitySolutionCases: allCasesCapabilities(),
observabilityCases: readCasesCapabilities(),
generalCases: noCasesCapabilities(),
};
const unrelatedFeatures = {
bogusCapability: {
crud_cases: true,
read_cases: true,
},
bogusCapability: allCasesCapabilities(),
};
const mockKibana = (permissionType: unknown = hasAll) => {
@ -57,7 +51,7 @@ describe('useAvailableCasesOwners correctly grabs user case permissions', () =>
mockKibana();
const { result } = renderHook(useAvailableCasesOwners);
expect(result.current).toEqual([SECURITY_SOLUTION_OWNER, OBSERVABILITY_OWNER]);
expect(result.current).toEqual([SECURITY_SOLUTION_OWNER, OBSERVABILITY_OWNER, APP_ID]);
});
it('returns no owner types if user has access to none', () => {
@ -68,17 +62,17 @@ describe('useAvailableCasesOwners correctly grabs user case permissions', () =>
});
it('returns only the permission it should have with CRUD as default', () => {
mockKibana(hasSecurityAsCrudAndObservabilityAsRead);
mockKibana(secAllObsReadGenNone);
const { result } = renderHook(useAvailableCasesOwners);
expect(result.current).toEqual([SECURITY_SOLUTION_OWNER]);
});
it('returns only the permission it should have with READ as default', () => {
mockKibana(hasSecurityAsCrudAndObservabilityAsRead);
const { result } = renderHook(() => useAvailableCasesOwners('read'));
mockKibana(secAllObsReadGenNone);
const { result } = renderHook(() => useAvailableCasesOwners(['read']));
expect(result.current).toEqual([OBSERVABILITY_OWNER]);
expect(result.current).toEqual([SECURITY_SOLUTION_OWNER, OBSERVABILITY_OWNER]);
});
it('returns no owners when the capabilities does not contain valid entries', () => {

View file

@ -5,27 +5,45 @@
* 2.0.
*/
import { APP_ID, FEATURE_ID } from '../../../common/constants';
import { useKibana } from '../../common/lib/kibana';
import { CasesPermissions } from '../../containers/types';
type Capability = Omit<keyof CasesPermissions, 'all'>;
/**
*
* @param level : 'crud' | 'read' (default: 'crud')
*
* `securitySolution` owner uses cases capability feature id: 'securitySolutionCases'; //owner
* `observability` owner uses cases capability feature id: 'observabilityCases';
* both solutions use `crud_cases` and `read_cases` capability names
* @param capabilities : specifies the requirements for a valid owner, an owner will be included if it has the specified
* capabilities
**/
export const useAvailableCasesOwners = (level: 'crud' | 'read' = 'crud'): string[] => {
const { capabilities } = useKibana().services.application;
const capabilityName = `${level}_cases`;
return Object.entries(capabilities).reduce(
(availableOwners: string[], [featureId, capability]) => {
if (featureId.endsWith('Cases') && !!capability[capabilityName]) {
availableOwners.push(featureId.replace('Cases', ''));
export const useAvailableCasesOwners = (
capabilities: Capability[] = ['create', 'read', 'update', 'delete', 'push']
): string[] => {
const { capabilities: kibanaCapabilities } = useKibana().services.application;
return Object.entries(kibanaCapabilities).reduce(
(availableOwners: string[], [featureId, kibananCapability]) => {
if (!featureId.endsWith('Cases')) {
return availableOwners;
}
for (const cap of capabilities) {
const hasCapability = !!kibananCapability[`${cap}_cases`];
if (!hasCapability) {
return availableOwners;
}
}
availableOwners.push(getOwnerFromFeatureID(featureId));
return availableOwners;
},
[]
);
};
const getOwnerFromFeatureID = (featureID: string) => {
if (featureID === FEATURE_ID) {
return APP_ID;
}
return featureID.replace('Cases', '');
};

View file

@ -10,6 +10,7 @@ import { useCallback, useEffect } from 'react';
import * as i18n from './translations';
import { useKibana } from '../../common/lib/kibana';
import { useCasesContext } from '../cases_context/use_cases_context';
import { isReadOnlyPermissions } from '../../utils/permissions';
/**
* This component places a read-only icon badge in the header if user only has read permissions
@ -20,7 +21,7 @@ export function useReadonlyHeader() {
// if the user is read only then display the glasses badge in the global navigation header
const setBadge = useCallback(() => {
if (!permissions.all && permissions.read) {
if (isReadOnlyPermissions(permissions)) {
chrome.setBadge({
text: i18n.READ_ONLY_BADGE_TEXT,
tooltip: i18n.READ_ONLY_BADGE_TOOLTIP,

View file

@ -9,7 +9,7 @@ import React from 'react';
import { mount } from 'enzyme';
import { useDeleteCases } from '../../containers/use_delete_cases';
import { TestProviders } from '../../common/mock';
import { noDeleteCasesPermissions, TestProviders } from '../../common/mock';
import { basicCase, basicPush } from '../../containers/mock';
import { Actions } from './actions';
import * as i18n from '../case_view/translations';
@ -67,6 +67,17 @@ describe('CaseView actions', () => {
expect(handleToggleModal).toHaveBeenCalled();
});
it('does not show trash icon when user does not have deletion privileges', () => {
const wrapper = mount(
<TestProviders permissions={noDeleteCasesPermissions()}>
<Actions {...defaultProps} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeFalsy();
expect(wrapper.find('button[data-test-subj="property-actions-ellipses"]').exists()).toBeFalsy();
});
it('toggle delete modal and confirm', () => {
useDeleteCasesMock.mockImplementation(() => ({
...defaultDeleteState,

View file

@ -7,6 +7,7 @@
import { isEmpty } from 'lodash/fp';
import React, { useMemo } from 'react';
import { EuiFlexItem } from '@elastic/eui';
import * as i18n from '../case_view/translations';
import { useDeleteCases } from '../../containers/use_delete_cases';
import { ConfirmDeleteCaseModal } from '../confirm_delete_case';
@ -14,6 +15,7 @@ import { PropertyActions } from '../property_actions';
import { Case } from '../../../common/ui/types';
import { CaseService } from '../../containers/use_get_case_user_actions';
import { useAllCasesNavigation } from '../../common/navigation';
import { useCasesContext } from '../cases_context/use_cases_context';
interface CaseViewActions {
caseData: Case;
@ -25,14 +27,19 @@ const ActionsComponent: React.FC<CaseViewActions> = ({ caseData, currentExternal
const { handleToggleModal, handleOnDeleteConfirm, isDeleted, isDisplayConfirmDeleteModal } =
useDeleteCases();
const { navigateToAllCases } = useAllCasesNavigation();
const { permissions } = useCasesContext();
const propertyActions = useMemo(
() => [
{
iconType: 'trash',
label: i18n.DELETE_CASE(),
onClick: handleToggleModal,
},
...(permissions.delete
? [
{
iconType: 'trash',
label: i18n.DELETE_CASE(),
onClick: handleToggleModal,
},
]
: []),
...(currentExternalIncident != null && !isEmpty(currentExternalIncident?.externalUrl)
? [
{
@ -43,15 +50,20 @@ const ActionsComponent: React.FC<CaseViewActions> = ({ caseData, currentExternal
]
: []),
],
[handleToggleModal, currentExternalIncident]
[handleToggleModal, currentExternalIncident, permissions.delete]
);
if (isDeleted) {
navigateToAllCases();
return null;
}
if (propertyActions.length === 0) {
return null;
}
return (
<>
<EuiFlexItem grow={false} data-test-subj="case-view-actions">
<PropertyActions propertyActions={propertyActions} />
<ConfirmDeleteCaseModal
caseTitle={caseData.title}
@ -59,7 +71,7 @@ const ActionsComponent: React.FC<CaseViewActions> = ({ caseData, currentExternal
onCancel={handleToggleModal}
onConfirm={handleOnDeleteConfirm.bind(null, [{ id: caseData.id, title: caseData.title }])}
/>
</>
</EuiFlexItem>
);
};
ActionsComponent.displayName = 'Actions';

View file

@ -7,11 +7,17 @@
import React from 'react';
import { mount } from 'enzyme';
import { render } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { basicCase, caseUserActions, getAlertUserAction } from '../../containers/mock';
import { CaseActionBar, CaseActionBarProps } from '.';
import { TestProviders } from '../../common/mock';
import {
allCasesPermissions,
noDeleteCasesPermissions,
noUpdateCasesPermissions,
TestProviders,
} from '../../common/mock';
import { useGetCaseUserActions } from '../../containers/use_get_case_user_actions';
import { useRefreshCaseViewPage } from '../case_view/use_on_refresh_case_view_page';
@ -188,4 +194,56 @@ describe('CaseActionBar', () => {
expect(getByText('Case opened')).toBeInTheDocument();
});
it('should show the change status text when the user has update privileges', () => {
render(
<TestProviders>
<CaseActionBar {...defaultProps} />
</TestProviders>
);
expect(screen.getByTitle('Change status')).toBeInTheDocument();
});
it('should not show the change status text when the user does not have update privileges', () => {
render(
<TestProviders permissions={noUpdateCasesPermissions()}>
<CaseActionBar {...defaultProps} />
</TestProviders>
);
expect(screen.queryByTitle('Change status')).not.toBeInTheDocument();
});
it('should not show the sync alerts toggle when the user does not have update privileges', () => {
const { queryByText } = render(
<TestProviders permissions={noUpdateCasesPermissions()}>
<CaseActionBar {...defaultProps} />
</TestProviders>
);
expect(queryByText('Sync alerts')).not.toBeInTheDocument();
});
it('should not show the delete item in the menu when the user does not have delete privileges', () => {
const { queryByText, queryByTestId } = render(
<TestProviders permissions={noDeleteCasesPermissions()}>
<CaseActionBar {...defaultProps} />
</TestProviders>
);
expect(queryByTestId('property-actions-ellipses')).not.toBeInTheDocument();
expect(queryByText('Delete case')).not.toBeInTheDocument();
});
it('should show the the delete item in the menu when the user does have delete privileges', () => {
const { queryByText } = render(
<TestProviders permissions={allCasesPermissions()}>
<CaseActionBar {...defaultProps} />
</TestProviders>
);
userEvent.click(screen.getByTestId('property-actions-ellipses'));
expect(queryByText('Delete case')).toBeInTheDocument();
});
});

View file

@ -107,7 +107,7 @@ const CaseActionBarComponent: React.FC<CaseActionBarProps> = ({
<EuiDescriptionListDescription>
<StatusContextMenu
currentStatus={caseData.status}
disabled={!permissions.all || isLoading}
disabled={!permissions.update || isLoading}
onStatusChanged={onStatusChanged}
/>
</EuiDescriptionListDescription>
@ -134,7 +134,7 @@ const CaseActionBarComponent: React.FC<CaseActionBarProps> = ({
responsive={false}
justifyContent="spaceBetween"
>
{permissions.all && isSyncAlertsEnabled && (
{permissions.update && isSyncAlertsEnabled && (
<EuiFlexItem grow={false}>
<EuiDescriptionListTitle>
<EuiFlexGroup
@ -172,14 +172,7 @@ const CaseActionBarComponent: React.FC<CaseActionBarProps> = ({
</EuiButtonEmpty>
</span>
</EuiFlexItem>
{permissions.all && (
<EuiFlexItem grow={false} data-test-subj="case-view-actions">
<Actions
caseData={caseData}
currentExternalIncident={currentExternalIncident}
/>
</EuiFlexItem>
)}
<Actions caseData={caseData} currentExternalIncident={currentExternalIncident} />
</EuiFlexGroup>
</EuiDescriptionList>
</EuiFlexItem>

View file

@ -13,7 +13,11 @@ import {
getAlertUserAction,
} from '../../../containers/mock';
import React from 'react';
import { AppMockRenderer, createAppMockRenderer } from '../../../common/mock';
import {
AppMockRenderer,
createAppMockRenderer,
noUpdateCasesPermissions,
} from '../../../common/mock';
import { CaseViewActivity } from './case_view_activity';
import { ConnectorTypes } from '../../../../common/api/connectors';
import { Case } from '../../../../common';
@ -110,6 +114,30 @@ describe('Case View Page activity tab', () => {
expect(useGetCaseUserActionsMock).toHaveBeenCalledWith(caseData.id, caseData.connector.id);
});
it('should not render the case view status button when the user does not have update permissions', () => {
appMockRender = createAppMockRenderer({ permissions: noUpdateCasesPermissions() });
const result = appMockRender.render(<CaseViewActivity {...caseProps} />);
expect(result.getByTestId('case-view-activity')).toBeTruthy();
expect(result.getByTestId('user-actions')).toBeTruthy();
expect(result.getByTestId('case-tags')).toBeTruthy();
expect(result.getByTestId('connector-edit-header')).toBeTruthy();
expect(result.queryByTestId('case-view-status-action-button')).not.toBeInTheDocument();
expect(useGetCaseUserActionsMock).toHaveBeenCalledWith(caseData.id, caseData.connector.id);
});
it('should disable the severity selector when the user does not have update permissions', () => {
appMockRender = createAppMockRenderer({ permissions: noUpdateCasesPermissions() });
const result = appMockRender.render(<CaseViewActivity {...caseProps} />);
expect(result.getByTestId('case-view-activity')).toBeTruthy();
expect(result.getByTestId('user-actions')).toBeTruthy();
expect(result.getByTestId('case-tags')).toBeTruthy();
expect(result.getByTestId('connector-edit-header')).toBeTruthy();
expect(result.getByTestId('case-severity-selection')).toBeDisabled();
expect(useGetCaseUserActionsMock).toHaveBeenCalledWith(caseData.id, caseData.connector.id);
});
it('should show a loading when is fetching data is true and hide the user actions activity', () => {
useGetCaseUserActionsMock.mockReturnValue({
...defaultUseGetCaseUserActions,

View file

@ -133,7 +133,7 @@ export const CaseViewActivity = ({
onShowAlertDetails={onShowAlertDetails}
onUpdateField={onUpdateField}
statusActionButton={
permissions.all ? (
permissions.update ? (
<StatusActionButton
status={caseData.status}
onStatusChanged={changeStatus}
@ -149,7 +149,7 @@ export const CaseViewActivity = ({
</EuiFlexItem>
<EuiFlexItem grow={2}>
<SeveritySidebarSelector
isDisabled={!permissions.all}
isDisabled={!permissions.update}
isLoading={isLoading}
selectedSeverity={caseData.severity}
onSeverityChange={onUpdateSeverity}

View file

@ -16,7 +16,7 @@ import {
casesContextReducer,
getInitialCasesContextState,
} from './cases_context_reducer';
import { CasesFeaturesAllRequired, CasesFeatures } from '../../containers/types';
import { CasesFeaturesAllRequired, CasesFeatures, CasesPermissions } from '../../containers/types';
import { CasesGlobalComponents } from './cases_global_components';
import { ReleasePhase } from '../types';
import { ExternalReferenceAttachmentTypeRegistry } from '../../client/attachment_framework/external_reference_registry';
@ -30,10 +30,7 @@ export interface CasesContextValue {
owner: string[];
appId: string;
appTitle: string;
permissions: {
all: boolean;
read: boolean;
};
permissions: CasesPermissions;
basePath: string;
features: CasesFeaturesAllRequired;
releasePhase: ReleasePhase;

View file

@ -10,7 +10,7 @@ import { ReactWrapper, mount } from 'enzyme';
import { waitFor } from '@testing-library/react';
import { ConfigureCases } from '.';
import { noCasesPermissions, TestProviders } from '../../common/mock';
import { noUpdateCasesPermissions, TestProviders } from '../../common/mock';
import { Connectors } from './connectors';
import { ClosureOptions } from './closure_options';
@ -188,10 +188,10 @@ describe('ConfigureCases', () => {
expect(wrapper.find('[data-test-subj="edit-connector-flyout"]').exists()).toBe(false);
});
test('it disables correctly when the user cannot crud', () => {
test('it disables correctly when the user cannot update', () => {
const newWrapper = mount(<ConfigureCases />, {
wrappingComponent: TestProviders,
wrappingComponentProps: { permissions: noCasesPermissions() },
wrappingComponentProps: { permissions: noUpdateCasesPermissions() },
});
expect(newWrapper.find('button[data-test-subj="dropdown-connectors"]').prop('disabled')).toBe(

View file

@ -225,7 +225,7 @@ export const ConfigureCases: React.FC = React.memo(() => {
<SectionWrapper>
<ClosureOptions
closureTypeSelected={closureType}
disabled={persistLoading || isLoadingConnectors || !permissions.all}
disabled={persistLoading || isLoadingConnectors || !permissions.update}
onChangeClosureType={onChangeClosureType}
/>
</SectionWrapper>
@ -233,13 +233,13 @@ export const ConfigureCases: React.FC = React.memo(() => {
<Connectors
actionTypes={actionTypes}
connectors={connectors ?? []}
disabled={persistLoading || isLoadingConnectors || !permissions.all}
disabled={persistLoading || isLoadingConnectors || !permissions.update}
handleShowEditFlyout={onClickUpdateConnector}
isLoading={isLoadingAny}
mappings={mappings}
onChangeConnector={onChangeConnector}
selectedConnector={connector}
updateConnectorDisabled={updateConnectorDisabled || !permissions.all}
updateConnectorDisabled={updateConnectorDisabled || !permissions.update}
/>
</SectionWrapper>
{ConnectorAddFlyout}

View file

@ -15,6 +15,7 @@ import {
AppMockRenderer,
createAppMockRenderer,
readCasesPermissions,
noPushCasesPermissions,
TestProviders,
} from '../../common/mock';
import { basicCase, basicPush, caseUserActions, connectorsMock } from '../../containers/mock';
@ -362,6 +363,16 @@ describe('EditConnector ', () => {
});
});
it('does not show the push button if the user does not have push permissions', async () => {
const defaultProps = getDefaultProps();
appMockRender = createAppMockRenderer({ permissions: noPushCasesPermissions() });
const result = appMockRender.render(<EditConnector {...defaultProps} />);
await waitFor(() => {
expect(result.queryByTestId('has-data-to-push-button')).toBe(null);
});
});
it('does not show the edit connectors pencil if the user does not have read access to actions', async () => {
const defaultProps = getDefaultProps();
const props = { ...defaultProps, connectors: [] };
@ -376,4 +387,16 @@ describe('EditConnector ', () => {
expect(result.queryByTestId('connector-edit')).toBe(null);
});
});
it('does not show the edit connectors pencil if the user does not have push permissions', async () => {
const defaultProps = getDefaultProps();
const props = { ...defaultProps, connectors: [] };
appMockRender = createAppMockRenderer({ permissions: noPushCasesPermissions() });
const result = appMockRender.render(<EditConnector {...props} />);
await waitFor(() => {
expect(result.getByTestId('connector-edit-header')).toBeInTheDocument();
expect(result.queryByTestId('connector-edit')).toBe(null);
});
});
});

View file

@ -288,7 +288,7 @@ export const EditConnector = React.memo(
<h4>{i18n.CONNECTORS}</h4>
</EuiFlexItem>
{isLoading && <EuiLoadingSpinner data-test-subj="connector-loading" />}
{!isLoading && !editConnector && permissions.all && actionsReadCapabilities && (
{!isLoading && !editConnector && permissions.push && actionsReadCapabilities && (
<EuiFlexItem data-test-subj="connector-edit" grow={false}>
<EuiButtonIcon
data-test-subj="connector-edit-button"
@ -316,7 +316,7 @@ export const EditConnector = React.memo(
connectors,
dataTestSubj: 'caseConnectors',
defaultValue: selectedConnector,
disabled: !permissions.all,
disabled: !permissions.push,
idAria: 'caseConnectors',
isEdit: editConnector,
isLoading,
@ -372,7 +372,7 @@ export const EditConnector = React.memo(
{pushCallouts == null &&
!isLoading &&
!editConnector &&
permissions.all &&
permissions.push &&
actionsReadCapabilities && (
<EuiFlexItem data-test-subj="has-data-to-push-button" grow={false}>
<span>{pushButton}</span>

View file

@ -44,7 +44,11 @@ exports[`EditableTitle renders 1`] = `
],
"permissions": Object {
"all": true,
"create": true,
"delete": true,
"push": true,
"read": true,
"update": true,
},
"persistableStateAttachmentTypeRegistry": PersistableStateAttachmentTypeRegistry {
"collection": Map {},

View file

@ -44,7 +44,11 @@ exports[`HeaderPage it renders 1`] = `
],
"permissions": Object {
"all": true,
"create": true,
"delete": true,
"push": true,
"read": true,
"update": true,
},
"persistableStateAttachmentTypeRegistry": PersistableStateAttachmentTypeRegistry {
"collection": Map {},

View file

@ -118,7 +118,7 @@ const EditableTitleComponent: React.FC<EditableTitleProps> = ({ onSubmit, isLoad
) : (
<Title title={title} releasePhase={releasePhase}>
{isLoading && <MySpinner data-test-subj="editable-title-loading" />}
{!isLoading && permissions.all && (
{!isLoading && permissions.update && (
<MyEuiButtonIcon
aria-label={i18n.EDIT_TITLE_ARIA(title as string)}
iconType="pencil"

View file

@ -25,7 +25,7 @@ describe('NoCases', () => {
);
});
it('displays a message without a link to create a case when the user does not have write permissions', () => {
it('displays a message without a link to create a case when the user does not have create permissions', () => {
const wrapper = mount(
<TestProviders permissions={readCasesPermissions()}>
<NoCases />

View file

@ -24,7 +24,7 @@ const NoCasesComponent = () => {
[navigateToCreateCase]
);
return permissions.all ? (
return permissions.create ? (
<>
<span>{i18n.NO_CASES}</span>
<LinkAnchor

View file

@ -107,7 +107,7 @@ describe('TagList ', () => {
expect(wrapper.find(`[data-test-subj="tag-pepsi"]`).last().exists()).toBeTruthy();
});
it('does not render when the user does not have write permissions', () => {
it('does not render when the user does not have update permissions', () => {
const wrapper = mount(
<TestProviders permissions={readCasesPermissions()}>
<TagList {...defaultProps} />

View file

@ -104,7 +104,7 @@ export const TagList = React.memo(({ isLoading, onSubmit, tags }: TagListProps)
<h4>{i18n.TAGS}</h4>
</EuiFlexItem>
{isLoading && <EuiLoadingSpinner data-test-subj="tag-list-loading" />}
{!isLoading && permissions.all && (
{!isLoading && permissions.update && (
<EuiFlexItem data-test-subj="tag-list-edit" grow={false}>
<EuiButtonIcon
data-test-subj="tag-list-edit-button"

View file

@ -11,7 +11,7 @@ import { render, screen } from '@testing-library/react';
import '../../common/mock/match_media';
import { usePushToService, ReturnUsePushToService, UsePushToService } from '.';
import { readCasesPermissions, TestProviders } from '../../common/mock';
import { noPushCasesPermissions, readCasesPermissions, TestProviders } from '../../common/mock';
import { CaseStatuses, ConnectorTypes } from '../../../common/api';
import { usePostPushToService } from '../../containers/use_post_push_to_service';
import { basicPush, actionLicenses, connectorsMock } from '../../containers/mock';
@ -280,6 +280,24 @@ describe('usePushToService', () => {
});
describe('user does not have write permissions', () => {
it('disables the push button when the user does not have push permissions', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<UsePushToService, ReturnUsePushToService>(
() => usePushToService(defaultArgs),
{
wrapper: ({ children }) => (
<TestProviders permissions={noPushCasesPermissions()}> {children}</TestProviders>
),
}
);
await waitForNextUpdate();
const { getByTestId } = render(result.current.pushButton);
expect(getByTestId('push-to-external-service')).toBeDisabled();
});
});
it('does not display a message when user does not have a premium license', async () => {
useFetchActionLicenseMock.mockImplementation(() => ({
isLoading: false,

View file

@ -76,7 +76,7 @@ export const usePushToService = ({
// these message require that the user do some sort of write action as a result of the message, readonly users won't
// be able to perform such an action so let's not display the error to the user in that situation
if (!permissions.all) {
if (!permissions.update) {
return errors;
}
@ -114,7 +114,7 @@ export const usePushToService = ({
return errors;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actionLicense, caseStatus, connectors.length, connector, loadingLicense, permissions.all]);
}, [actionLicense, caseStatus, connectors.length, connector, loadingLicense, permissions.update]);
const pushToServiceButton = useMemo(
() => (
@ -126,7 +126,7 @@ export const usePushToService = ({
isLoading ||
loadingLicense ||
errorsMsg.length > 0 ||
!permissions.all ||
!permissions.push ||
!isValidConnector ||
!hasDataToPush
}
@ -146,13 +146,13 @@ export const usePushToService = ({
hasDataToPush,
isLoading,
loadingLicense,
permissions.all,
permissions.push,
isValidConnector,
]
);
const objToReturn = useMemo(() => {
const hidePushButton = errorsMsg.length > 0 || !hasDataToPush || !permissions.all;
const hidePushButton = errorsMsg.length > 0 || !hasDataToPush || !permissions.push;
return {
pushButton: hidePushButton ? (
@ -184,7 +184,7 @@ export const usePushToService = ({
hasLicenseError,
onEditClick,
pushToServiceButton,
permissions.all,
permissions.push,
]);
return objToReturn;

View file

@ -233,7 +233,7 @@ export const UserActions = React.memo(
const { permissions } = useCasesContext();
const bottomActions = permissions.all
const bottomActions = permissions.create
? [
{
username: (

View file

@ -6,11 +6,15 @@
*/
import React from 'react';
import { mount, ReactWrapper } from 'enzyme';
import { UserActionPropertyActions } from './property_actions';
import { render } from '@testing-library/react';
import { UserActionPropertyActions, UserActionPropertyActionsProps } from './property_actions';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TestProviders } from '../../common/mock';
import {
noCreateCasesPermissions,
noDeleteCasesPermissions,
noUpdateCasesPermissions,
TestProviders,
} from '../../common/mock';
jest.mock('../../common/lib/kibana');
@ -28,67 +32,123 @@ const props = {
};
describe('UserActionPropertyActions ', () => {
let wrapper: ReactWrapper;
beforeAll(() => {
wrapper = mount(
<TestProviders>
<UserActionPropertyActions {...props} />
</TestProviders>
);
});
beforeEach(() => {
jest.clearAllMocks();
});
it('renders', async () => {
expect(
wrapper.find('[data-test-subj="user-action-title-loading"]').first().exists()
).toBeFalsy();
render(
<TestProviders>
<UserActionPropertyActions {...props} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="property-actions"]').first().exists()).toBeTruthy();
expect(screen.queryByTestId('user-action-title-loading')).not.toBeInTheDocument();
expect(screen.getByTestId('property-actions')).toBeInTheDocument();
});
it('shows the edit and quote buttons', async () => {
wrapper.find('[data-test-subj="property-actions-ellipses"]').first().simulate('click');
wrapper.find('[data-test-subj="property-actions-pencil"]').exists();
wrapper.find('[data-test-subj="property-actions-quote"]').exists();
const renderResult = render(
<TestProviders>
<UserActionPropertyActions {...props} />
</TestProviders>
);
userEvent.click(renderResult.getByTestId('property-actions-ellipses'));
expect(screen.getByTestId('property-actions-pencil')).toBeInTheDocument();
expect(screen.getByTestId('property-actions-quote')).toBeInTheDocument();
});
it('quote click calls onQuote', async () => {
wrapper.find('[data-test-subj="property-actions-ellipses"]').first().simulate('click');
wrapper.find('[data-test-subj="property-actions-quote"]').first().simulate('click');
const renderResult = render(
<TestProviders>
<UserActionPropertyActions {...props} />
</TestProviders>
);
userEvent.click(renderResult.getByTestId('property-actions-ellipses'));
userEvent.click(renderResult.getByTestId('property-actions-quote'));
expect(onQuote).toHaveBeenCalledWith(props.id);
});
it('pencil click calls onEdit', async () => {
wrapper.find('[data-test-subj="property-actions-ellipses"]').first().simulate('click');
wrapper.find('[data-test-subj="property-actions-pencil"]').first().simulate('click');
const renderResult = render(
<TestProviders>
<UserActionPropertyActions {...props} />
</TestProviders>
);
userEvent.click(renderResult.getByTestId('property-actions-ellipses'));
userEvent.click(renderResult.getByTestId('property-actions-pencil'));
expect(onEdit).toHaveBeenCalledWith(props.id);
});
it('shows the spinner when loading', async () => {
wrapper = mount(
render(
<TestProviders>
<UserActionPropertyActions {...props} isLoading={true} />
</TestProviders>
);
expect(
wrapper.find('[data-test-subj="user-action-title-loading"]').first().exists()
).toBeTruthy();
expect(wrapper.find('[data-test-subj="property-actions"]').first().exists()).toBeFalsy();
expect(screen.getByTestId('user-action-title-loading')).toBeInTheDocument();
expect(screen.queryByTestId('property-actions')).not.toBeInTheDocument();
});
describe('Delete button', () => {
const onDelete = jest.fn();
const deleteProps = {
...props,
onDelete,
deleteLabel: 'delete me',
deleteConfirmlabel: 'confirm delete me',
};
describe('deletion props', () => {
let onDelete: jest.Mock;
let deleteProps: UserActionPropertyActionsProps;
beforeEach(() => {
jest.clearAllMocks();
onDelete = jest.fn();
deleteProps = {
...props,
onDelete,
deleteLabel: 'delete me',
deleteConfirmTitle: 'confirm delete me',
};
});
it('does not show the delete icon when the user does not have delete permissions', () => {
const renderResult = render(
<TestProviders permissions={noDeleteCasesPermissions()}>
<UserActionPropertyActions {...deleteProps} />
</TestProviders>
);
userEvent.click(renderResult.getByTestId('property-actions-ellipses'));
expect(renderResult.queryByTestId('property-actions-trash')).not.toBeInTheDocument();
expect(renderResult.queryByTestId('property-actions-pencil')).toBeInTheDocument();
expect(renderResult.queryByTestId('property-actions-quote')).toBeInTheDocument();
});
it('does not show the pencil icon when the user does not have update permissions', () => {
const renderResult = render(
<TestProviders permissions={noUpdateCasesPermissions()}>
<UserActionPropertyActions {...deleteProps} />
</TestProviders>
);
userEvent.click(renderResult.getByTestId('property-actions-ellipses'));
expect(renderResult.queryByTestId('property-actions-trash')).toBeInTheDocument();
expect(renderResult.queryByTestId('property-actions-pencil')).not.toBeInTheDocument();
expect(renderResult.queryByTestId('property-actions-quote')).toBeInTheDocument();
});
it('does not show the quote icon when the user does not have create permissions', () => {
const renderResult = render(
<TestProviders permissions={noCreateCasesPermissions()}>
<UserActionPropertyActions {...deleteProps} />
</TestProviders>
);
userEvent.click(renderResult.getByTestId('property-actions-ellipses'));
expect(renderResult.queryByTestId('property-actions-trash')).toBeInTheDocument();
expect(renderResult.queryByTestId('property-actions-pencil')).toBeInTheDocument();
expect(renderResult.queryByTestId('property-actions-quote')).not.toBeInTheDocument();
});
it('shows the delete button', () => {
const renderResult = render(
<TestProviders>

View file

@ -13,7 +13,7 @@ import { useLensOpenVisualization } from '../markdown_editor/plugins/lens/use_le
import { CANCEL_BUTTON, CONFIRM_BUTTON } from './translations';
import { useCasesContext } from '../cases_context/use_cases_context';
interface UserActionPropertyActionsProps {
export interface UserActionPropertyActionsProps {
id: string;
editLabel: string;
deleteLabel?: string;
@ -59,47 +59,56 @@ const UserActionPropertyActionsComponent = ({
setShowDeleteConfirm(false);
}, []);
const propertyActions = useMemo(
() =>
[
permissions.all
? [
{
iconType: 'pencil',
label: editLabel,
onClick: onEditClick,
},
...(deleteLabel && onDelete
? [
{
iconType: 'trash',
label: deleteLabel,
onClick: onDeleteClick,
},
]
: []),
{
iconType: 'quote',
label: quoteLabel,
onClick: onQuoteClick,
},
]
: [],
canUseEditor && actionConfig ? [actionConfig] : [],
].flat(),
[
permissions.all,
editLabel,
onEditClick,
deleteLabel,
onDelete,
onDeleteClick,
quoteLabel,
onQuoteClick,
canUseEditor,
actionConfig,
]
);
const propertyActions = useMemo(() => {
const showEditPencilIcon = permissions.update;
const showTrashIcon = permissions.delete && deleteLabel && onDelete;
const showQuoteIcon = permissions.create;
const showLensEditor = permissions.update && canUseEditor && actionConfig;
return [
...(showEditPencilIcon
? [
{
iconType: 'pencil',
label: editLabel,
onClick: onEditClick,
},
]
: []),
...(showTrashIcon
? [
{
iconType: 'trash',
label: deleteLabel,
onClick: onDeleteClick,
},
]
: []),
...(showQuoteIcon
? [
{
iconType: 'quote',
label: quoteLabel,
onClick: onQuoteClick,
},
]
: []),
...(showLensEditor ? [actionConfig] : []),
];
}, [
permissions.update,
permissions.delete,
permissions.create,
deleteLabel,
onDelete,
canUseEditor,
actionConfig,
editLabel,
onEditClick,
onDeleteClick,
quoteLabel,
onQuoteClick,
]);
if (!propertyActions.length) {
return null;

View file

@ -28,6 +28,14 @@ const hooksMock: jest.Mocked<CasesUiStart['hooks']> = {
const helpersMock: jest.Mocked<CasesUiStart['helpers']> = {
canUseCases: jest.fn(),
getUICapabilities: jest.fn().mockReturnValue({
all: false,
create: false,
read: false,
update: false,
delete: false,
push: false,
}),
getRuleIdFromEvent: jest.fn(),
groupAlertsByRule: jest.fn(),
};

View file

@ -24,6 +24,7 @@ import { getCasesContextLazy } from './client/ui/get_cases_context';
import { getCreateCaseFlyoutLazy } from './client/ui/get_create_case_flyout';
import { getRecentCasesLazy } from './client/ui/get_recent_cases';
import { groupAlertsByRule } from './client/helpers/group_alerts_by_rule';
import { getUICapabilities } from './client/helpers/capabilities';
import { ExternalReferenceAttachmentTypeRegistry } from './client/attachment_framework/external_reference_registry';
import { PersistableStateAttachmentTypeRegistry } from './client/attachment_framework/persistable_state_registry';
@ -150,6 +151,7 @@ export class CasesUiPlugin
},
helpers: {
canUseCases: canUseCases(core.application.capabilities),
getUICapabilities,
getRuleIdFromEvent,
groupAlertsByRule,
},

View file

@ -29,7 +29,7 @@ import type {
} from '../common/api';
import type { UseCasesAddToExistingCaseModal } from './components/all_cases/selector_modal/use_cases_add_to_existing_case_modal';
import type { UseCasesAddToNewCaseFlyout } from './components/create/flyout/use_cases_add_to_new_case_flyout';
import type { CasesOwners } from './client/helpers/can_use_cases';
import { canUseCases } from './client/helpers/can_use_cases';
import { getRuleIdFromEvent } from './client/helpers/get_rule_id_from_event';
import type { GetCasesContextProps } from './client/ui/get_cases_context';
import type { GetCasesProps } from './client/ui/get_cases';
@ -38,6 +38,7 @@ import type { GetCreateCaseFlyoutProps } from './client/ui/get_create_case_flyou
import type { GetRecentCasesProps } from './client/ui/get_recent_cases';
import type { Cases, CasesStatus, CasesMetrics } from '../common/ui';
import { groupAlertsByRule } from './client/helpers/group_alerts_by_rule';
import { getUICapabilities } from './client/helpers/capabilities';
import type { AttachmentFramework } from './client/attachment_framework/types';
import { ExternalReferenceAttachmentTypeRegistry } from './client/attachment_framework/external_reference_registry';
import { PersistableStateAttachmentTypeRegistry } from './client/attachment_framework/persistable_state_registry';
@ -137,7 +138,8 @@ export interface CasesUiStart {
* @param owners an array of CaseOwners that should be queried for permission
* @returns An object denoting the case permissions of the current user
*/
canUseCases: (owners?: CasesOwners[]) => { crud: boolean; read: boolean };
canUseCases: ReturnType<typeof canUseCases>;
getUICapabilities: typeof getUICapabilities;
getRuleIdFromEvent: typeof getRuleIdFromEvent;
groupAlertsByRule: typeof groupAlertsByRule;
};

View file

@ -0,0 +1,19 @@
/*
* 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 { CasesPermissions } from '../../common';
export const isReadOnlyPermissions = (permissions: CasesPermissions) => {
return (
!permissions.all &&
!permissions.create &&
!permissions.update &&
!permissions.delete &&
!permissions.push &&
permissions.read
);
};

View file

@ -11,6 +11,7 @@ import { KibanaFeatureConfig } from '@kbn/features-plugin/common';
import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server';
import { APP_ID, FEATURE_ID } from '../common/constants';
import { createUICapabilities } from '../common';
/**
* The order of appearance in the feature privilege page
@ -20,44 +21,81 @@ import { APP_ID, FEATURE_ID } from '../common/constants';
const FEATURE_ORDER = 3100;
export const getCasesKibanaFeature = (): KibanaFeatureConfig => ({
id: FEATURE_ID,
name: i18n.translate('xpack.cases.features.casesFeatureName', {
defaultMessage: 'Cases',
}),
category: DEFAULT_APP_CATEGORIES.management,
app: [],
order: FEATURE_ORDER,
management: {
insightsAndAlerting: [APP_ID],
},
cases: [APP_ID],
privileges: {
all: {
cases: {
all: [APP_ID],
},
management: {
insightsAndAlerting: [APP_ID],
},
savedObject: {
all: [],
read: [],
},
ui: ['crud_cases', 'read_cases'],
export const getCasesKibanaFeature = (): KibanaFeatureConfig => {
const capabilities = createUICapabilities();
return {
id: FEATURE_ID,
name: i18n.translate('xpack.cases.features.casesFeatureName', {
defaultMessage: 'Cases',
}),
category: DEFAULT_APP_CATEGORIES.management,
app: [],
order: FEATURE_ORDER,
management: {
insightsAndAlerting: [APP_ID],
},
read: {
cases: {
read: [APP_ID],
cases: [APP_ID],
privileges: {
all: {
cases: {
create: [APP_ID],
read: [APP_ID],
update: [APP_ID],
push: [APP_ID],
},
management: {
insightsAndAlerting: [APP_ID],
},
savedObject: {
all: [],
read: [],
},
ui: capabilities.all,
},
management: {
insightsAndAlerting: [APP_ID],
read: {
cases: {
read: [APP_ID],
},
management: {
insightsAndAlerting: [APP_ID],
},
savedObject: {
all: [],
read: [],
},
ui: capabilities.read,
},
savedObject: {
all: [],
read: [],
},
ui: ['read_cases'],
},
},
});
subFeatures: [
{
name: i18n.translate('xpack.cases.features.deleteSubFeatureName', {
defaultMessage: 'Delete',
}),
privilegeGroups: [
{
groupType: 'independent',
privileges: [
{
api: [],
id: 'cases_delete',
name: i18n.translate('xpack.cases.features.deleteSubFeatureDetails', {
defaultMessage: 'Delete cases and comments',
}),
includeIn: 'all',
savedObject: {
all: [],
read: [],
},
cases: {
delete: [APP_ID],
},
ui: capabilities.delete,
},
],
},
],
},
],
};
};

View file

@ -12,6 +12,7 @@ import { sampleAttribute } from '../../configurations/test_data/sample_attribute
import * as pluginHook from '../../../../../hooks/use_plugin_context';
import { TypedLensByValueInput } from '@kbn/lens-plugin/public';
import { ExpViewActionMenuContent } from './action_menu';
import { noCasesPermissions as mockUseGetCasesPermissions } from '../../../../../utils/cases_permissions';
jest.spyOn(pluginHook, 'usePluginContext').mockReturnValue({
appMountParameters: {
@ -19,7 +20,15 @@ jest.spyOn(pluginHook, 'usePluginContext').mockReturnValue({
},
} as any);
jest.mock('../../../../../hooks/use_get_user_cases_permissions', () => ({
useGetUserCasesPermissions: jest.fn(() => mockUseGetCasesPermissions()),
}));
describe('Action Menu', function () {
afterAll(() => {
jest.clearAllMocks();
});
it('should be able to click open in lens', async function () {
const { findByText, core } = render(
<ExpViewActionMenuContent

View file

@ -12,12 +12,18 @@ import { ExploratoryView } from './exploratory_view';
import * as obsvDataViews from '../../../utils/observability_data_views/observability_data_views';
import * as pluginHook from '../../../hooks/use_plugin_context';
import { createStubIndexPattern } from '@kbn/data-plugin/common/stubs';
import { noCasesPermissions as mockUseGetCasesPermissions } from '../../../utils/cases_permissions';
jest.spyOn(pluginHook, 'usePluginContext').mockReturnValue({
appMountParameters: {
setHeaderActionMenu: jest.fn(),
},
} as any);
jest.mock('../../../hooks/use_get_user_cases_permissions', () => ({
useGetUserCasesPermissions: jest.fn(() => mockUseGetCasesPermissions()),
}));
describe('ExploratoryView', () => {
mockAppDataView();

View file

@ -11,8 +11,12 @@ import { fireEvent } from '@testing-library/dom';
import { AddToCaseAction } from './add_to_case_action';
import * as useCaseHook from '../hooks/use_add_to_case';
import * as datePicker from '../components/date_range_picker';
import * as useGetUserCasesPermissionsModule from '../../../../hooks/use_get_user_cases_permissions';
import moment from 'moment';
import { noCasesPermissions as mockUseGetCasesPermissions } from '../../../../utils/cases_permissions';
jest.mock('../../../../hooks/use_get_user_cases_permissions', () => ({
useGetUserCasesPermissions: jest.fn(() => mockUseGetCasesPermissions()),
}));
describe('AddToCaseAction', function () {
beforeEach(() => {
@ -82,10 +86,6 @@ describe('AddToCaseAction', function () {
});
it('should be able to click add to case button', async function () {
const mockUseGetUserCasesPermissions = jest
.spyOn(useGetUserCasesPermissionsModule, 'useGetUserCasesPermissions')
.mockImplementation(() => ({ crud: false, read: false }));
const initSeries = {
data: [
{
@ -113,11 +113,13 @@ describe('AddToCaseAction', function () {
owner: ['observability'],
permissions: {
all: false,
create: false,
read: false,
update: false,
delete: false,
push: false,
},
})
);
mockUseGetUserCasesPermissions.mockRestore();
});
});

View file

@ -39,8 +39,7 @@ export function AddToCaseAction({
timeRange,
}: AddToCaseProps) {
const kServices = useKibana<ObservabilityAppServices>().services;
const userPermissions = useGetUserCasesPermissions();
const casesPermissions = { all: userPermissions.crud, read: userPermissions.read };
const userCasesPermissions = useGetUserCasesPermissions();
const {
cases,
@ -77,7 +76,7 @@ export function AddToCaseAction({
});
const getAllCasesSelectorModalProps: GetAllCasesSelectorModalProps = {
permissions: casesPermissions,
permissions: userCasesPermissions,
onRowClick: onCaseClicked,
owner: [owner],
onClose: () => {

View file

@ -25,8 +25,7 @@ export interface UseAddToCaseActions {
export const useBulkAddToCaseActions = ({ onClose, onSuccess }: UseAddToCaseActions = {}) => {
const { cases: casesUi } = useKibana<ObservabilityAppServices>().services;
const casePermissions = useGetUserCasesPermissions();
const hasWritePermissions = casePermissions?.crud ?? false;
const userCasesPermissions = useGetUserCasesPermissions();
const createCaseFlyout = casesUi.hooks.getUseCasesAddToNewCaseFlyout({
onClose,
@ -38,7 +37,7 @@ export const useBulkAddToCaseActions = ({ onClose, onSuccess }: UseAddToCaseActi
});
return useMemo(() => {
return hasWritePermissions
return userCasesPermissions.create && userCasesPermissions.read
? [
{
label: ADD_TO_NEW_CASE,
@ -68,5 +67,11 @@ export const useBulkAddToCaseActions = ({ onClose, onSuccess }: UseAddToCaseActi
},
]
: [];
}, [casesUi.helpers, createCaseFlyout, hasWritePermissions, selectCaseModal]);
}, [
casesUi.helpers,
createCaseFlyout,
userCasesPermissions.create,
userCasesPermissions.read,
selectCaseModal,
]);
};

View file

@ -1,79 +0,0 @@
/*
* 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 { renderHook } from '@testing-library/react-hooks';
import { applicationServiceMock } from '@kbn/core/public/mocks';
import { casesFeatureId } from '../../common';
import { useGetUserCasesPermissions } from './use_get_user_cases_permissions';
import { kibanaStartMock } from '../utils/kibana_react.mock';
let mockUseKibanaReturnValue = kibanaStartMock.startContract();
jest.mock('../utils/kibana_react', () => ({
__esModule: true,
useKibana: jest.fn(() => mockUseKibanaReturnValue),
}));
describe('useGetUserCasesPermissions', function () {
it('returns expected permissions when capabilities entry exists', () => {
mockUseKibanaReturnValue = {
...mockUseKibanaReturnValue,
services: {
...mockUseKibanaReturnValue.services,
application: {
...mockUseKibanaReturnValue.services.application,
capabilities: {
...applicationServiceMock.createStartContract().capabilities,
[casesFeatureId]: { crud_cases: false, read_cases: true },
},
},
},
};
const { result } = renderHook(() => useGetUserCasesPermissions(), {});
expect(result.current?.read).toBe(true);
expect(result.current?.crud).toBe(false);
});
it('returns false when capabilities entry permissions are missing', () => {
mockUseKibanaReturnValue = {
...mockUseKibanaReturnValue,
services: {
...mockUseKibanaReturnValue.services,
application: {
...mockUseKibanaReturnValue.services.application,
capabilities: {
...applicationServiceMock.createStartContract().capabilities,
[casesFeatureId]: {
/* intentionally empty */
},
},
},
},
};
const { result } = renderHook(() => useGetUserCasesPermissions(), {});
expect(result.current?.read).toBe(false);
expect(result.current?.crud).toBe(false);
});
it('returns false when capabilities entry is missing entirely', () => {
mockUseKibanaReturnValue = {
...mockUseKibanaReturnValue,
services: {
...mockUseKibanaReturnValue.services,
application: {
...mockUseKibanaReturnValue.services.application,
capabilities: {
...applicationServiceMock.createStartContract().capabilities,
},
},
},
};
const { result } = renderHook(() => useGetUserCasesPermissions(), {});
expect(result.current?.read).toBe(false);
expect(result.current?.crud).toBe(false);
});
});

View file

@ -6,35 +6,42 @@
*/
import { useEffect, useState } from 'react';
import { CasesPermissions } from '@kbn/cases-plugin/common';
import { useKibana } from '../utils/kibana_react';
import { casesFeatureId } from '../../common';
export interface UseGetUserCasesPermissions {
crud: boolean;
read: boolean;
}
export function useGetUserCasesPermissions() {
const [casesPermissions, setCasesPermissions] = useState<UseGetUserCasesPermissions>({
crud: false,
const [casesPermissions, setCasesPermissions] = useState<CasesPermissions>({
all: false,
read: false,
create: false,
update: false,
delete: false,
push: false,
});
const uiCapabilities = useKibana().services.application.capabilities;
const casesCapabilities = useKibana().services.cases.helpers.getUICapabilities(
uiCapabilities[casesFeatureId]
);
useEffect(() => {
const capabilitiesCanUserCRUD: boolean =
typeof uiCapabilities[casesFeatureId]?.crud_cases === 'boolean'
? (uiCapabilities[casesFeatureId].crud_cases as boolean)
: false;
const capabilitiesCanUserRead: boolean =
typeof uiCapabilities[casesFeatureId]?.read_cases === 'boolean'
? (uiCapabilities[casesFeatureId].read_cases as boolean)
: false;
setCasesPermissions({
crud: capabilitiesCanUserCRUD,
read: capabilitiesCanUserRead,
all: casesCapabilities.all,
create: casesCapabilities.create,
read: casesCapabilities.read,
update: casesCapabilities.update,
delete: casesCapabilities.delete,
push: casesCapabilities.push,
});
}, [uiCapabilities]);
}, [
casesCapabilities.all,
casesCapabilities.create,
casesCapabilities.read,
casesCapabilities.update,
casesCapabilities.delete,
casesCapabilities.push,
]);
return casesPermissions;
}

View file

@ -5,16 +5,7 @@
* 2.0.
*/
const casesUiStartMock = {
createStart() {
return {
getCases: jest.fn(),
getAllCasesSelectorModal: jest.fn(),
getCreateCaseFlyout: jest.fn(),
getRecentCases: jest.fn(),
};
},
};
import { mockCasesContract } from '@kbn/cases-plugin/public/mocks';
const embeddableStartMock = {
createStart() {
@ -60,7 +51,7 @@ const triggersActionsUiStartMock = {
export const observabilityPublicPluginsStartMock = {
createStart() {
return {
cases: casesUiStartMock.createStart(),
cases: mockCasesContract(),
embeddable: embeddableStartMock.createStart(),
triggersActionsUi: triggersActionsUiStartMock.createStart(),
data: null,

View file

@ -219,8 +219,7 @@ function AlertsPage() {
const hasData = hasAnyData === true || (isAllRequestsComplete === false ? undefined : false);
const CasesContext = cases.ui.getCasesContext();
const userPermissions = useGetUserCasesPermissions();
const casesPermissions = { all: userPermissions.crud, read: userPermissions.read };
const userCasesPermissions = useGetUserCasesPermissions();
if (!hasAnyData && !isAllRequestsComplete) {
return <LoadingObservability />;
@ -266,7 +265,7 @@ function AlertsPage() {
<EuiFlexItem>
<CasesContext
owner={[observabilityFeatureId]}
permissions={casesPermissions}
permissions={userCasesPermissions}
features={{ alerts: { sync: false } }}
>
<AlertsTableTGrid

View file

@ -168,7 +168,7 @@ function ObservabilityActions({
setActionsPopover((current) => (current ? null : id));
}, []);
const casePermissions = useGetUserCasesPermissions();
const userCasesPermissions = useGetUserCasesPermissions();
const ruleId = alert.fields['kibana.alert.rule.uuid'] ?? null;
const linkToRule = ruleId ? http.basePath.prepend(paths.observability.ruleDetails(ruleId)) : null;
const caseAttachments: CaseAttachments = useMemo(() => {
@ -201,7 +201,7 @@ function ObservabilityActions({
const actionsMenuItems = useMemo(() => {
return [
...(casePermissions.crud
...(userCasesPermissions.create && userCasesPermissions.read
? [
<EuiContextMenuItem
data-test-subj="add-to-existing-case-action"
@ -246,7 +246,8 @@ function ObservabilityActions({
],
];
}, [
casePermissions.crud,
userCasesPermissions.create,
userCasesPermissions.read,
handleAddToExistingCaseClick,
handleAddToNewCaseClick,
linkToRule,
@ -332,7 +333,7 @@ export function AlertsTableTGrid(props: AlertsTableTGridProps) {
storage.get(stateStorageKey)
);
const casePermissions = useGetUserCasesPermissions();
const userCasesPermissions = useGetUserCasesPermissions();
const hasAlertsCrudPermissions = useCallback(
({ ruleConsumer, ruleProducer }: { ruleConsumer: string; ruleProducer?: string }) => {
@ -415,7 +416,7 @@ export function AlertsTableTGrid(props: AlertsTableTGridProps) {
return {
appId: observabilityAppId,
casesOwner: observabilityFeatureId,
casePermissions,
casePermissions: userCasesPermissions,
type,
columns: (tGridState?.columns ?? columns).map(addDisplayNames),
deletedEventIds,
@ -464,7 +465,7 @@ export function AlertsTableTGrid(props: AlertsTableTGridProps) {
unit: (totalAlerts: number) => translations.alertsTable.showingAlertsTitle(totalAlerts),
};
}, [
casePermissions,
userCasesPermissions,
tGridState?.columns,
tGridState?.sort,
deletedEventIds,

View file

@ -7,18 +7,18 @@
import React, { Suspense, useCallback, useState } from 'react';
import { CasesPermissions } from '@kbn/cases-plugin/common';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { CASES_OWNER, CASES_PATH } from './constants';
import { usePluginContext } from '../../hooks/use_plugin_context';
import { LazyAlertsFlyout } from '../..';
import { useFetchAlertDetail } from './use_fetch_alert_detail';
import { useFetchAlertData } from './use_fetch_alert_data';
import { UseGetUserCasesPermissions } from '../../hooks/use_get_user_cases_permissions';
import { paths } from '../../config';
import { ObservabilityAppServices } from '../../application/types';
interface CasesProps {
permissions: UseGetUserCasesPermissions;
permissions: CasesPermissions;
}
export const Cases = React.memo<CasesProps>(({ permissions }) => {
const {
@ -30,7 +30,6 @@ export const Cases = React.memo<CasesProps>(({ permissions }) => {
} = useKibana<ObservabilityAppServices>().services;
const { observabilityRuleTypeRegistry } = usePluginContext();
const [selectedAlertId, setSelectedAlertId] = useState<string>('');
const casesPermissions = { all: permissions.crud, read: permissions.read };
const handleFlyoutClose = useCallback(() => {
setSelectedAlertId('');
@ -51,7 +50,7 @@ export const Cases = React.memo<CasesProps>(({ permissions }) => {
)}
{cases.ui.getCases({
basePath: CASES_PATH,
permissions: casesPermissions,
permissions,
owner: [CASES_OWNER],
features: { alerts: { sync: false } },
useFetchAlertData,

View file

@ -19,7 +19,7 @@ import { getNoDataConfig } from '../../utils/no_data_config';
import { ObservabilityAppServices } from '../../application/types';
export const CasesPage = React.memo(() => {
const userPermissions = useGetUserCasesPermissions();
const userCasesPermissions = useGetUserCasesPermissions();
const { docLinks, http } = useKibana<ObservabilityAppServices>().services;
const { ObservabilityPageTemplate } = usePluginContext();
@ -38,13 +38,13 @@ export const CasesPage = React.memo(() => {
docsLink: docLinks.links.observability.guide,
});
return userPermissions.read ? (
return userCasesPermissions.read ? (
<ObservabilityPageTemplate
isPageDataLoaded={Boolean(hasAnyData || isAllRequestsComplete)}
data-test-subj={noDataConfig ? 'noDataPage' : undefined}
noDataConfig={noDataConfig}
>
<Cases permissions={userPermissions} />
<Cases permissions={userCasesPermissions} />
</ObservabilityPageTemplate>
) : (
<CaseFeatureNoPermissions />

View file

@ -127,8 +127,7 @@ export function OverviewPage({ routeParams }: Props) {
}, []);
const CasesContext = cases.ui.getCasesContext();
const userPermissions = useGetUserCasesPermissions();
const casesPermissions = { all: userPermissions.crud, read: userPermissions.read };
const userCasesPermissions = useGetUserCasesPermissions();
useEffect(() => {
if (hasAnyData !== true) {
@ -200,7 +199,7 @@ export function OverviewPage({ routeParams }: Props) {
>
<CasesContext
owner={[observabilityFeatureId]}
permissions={casesPermissions}
permissions={userCasesPermissions}
features={{ alerts: { sync: false } }}
>
<AlertsTableTGrid

View file

@ -0,0 +1,15 @@
/*
* 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.
*/
export const noCasesPermissions = () => ({
all: false,
create: false,
read: false,
update: false,
delete: false,
push: false,
});

View file

@ -14,6 +14,7 @@ import {
} from '@kbn/core/server';
import { RuleRegistryPluginSetupContract } from '@kbn/rule-registry-plugin/server';
import { PluginSetupContract as FeaturesSetup } from '@kbn/features-plugin/server';
import { createUICapabilities } from '@kbn/cases-plugin/common';
import { ObservabilityConfig } from '.';
import {
bootstrapAnnotations,
@ -40,6 +41,8 @@ export class ObservabilityPlugin implements Plugin<ObservabilityPluginSetup> {
public setup(core: CoreSetup, plugins: PluginSetup) {
const config = this.initContext.config.get<ObservabilityConfig>();
const casesCapabilities = createUICapabilities();
plugins.features.registerKibanaFeature({
id: casesFeatureId,
name: i18n.translate('xpack.observability.featureRegistry.linkObservabilityTitle', {
@ -55,14 +58,17 @@ export class ObservabilityPlugin implements Plugin<ObservabilityPluginSetup> {
app: [casesFeatureId, 'kibana'],
catalogue: [observabilityFeatureId],
cases: {
all: [observabilityFeatureId],
create: [observabilityFeatureId],
read: [observabilityFeatureId],
update: [observabilityFeatureId],
push: [observabilityFeatureId],
},
api: [],
savedObject: {
all: [],
read: [],
},
ui: ['crud_cases', 'read_cases'], // uiCapabilities[casesFeatureId].crud_cases or read_cases
ui: casesCapabilities.all,
},
read: {
app: [casesFeatureId, 'kibana'],
@ -75,9 +81,42 @@ export class ObservabilityPlugin implements Plugin<ObservabilityPluginSetup> {
all: [],
read: [],
},
ui: ['read_cases'], // uiCapabilities[uiCapabilities[casesFeatureId]].read_cases
ui: casesCapabilities.read,
},
},
subFeatures: [
{
name: i18n.translate('xpack.observability.featureRegistry.deleteSubFeatureName', {
defaultMessage: 'Delete',
}),
privilegeGroups: [
{
groupType: 'independent',
privileges: [
{
api: [],
id: 'cases_delete',
name: i18n.translate(
'xpack.observability.featureRegistry.deleteSubFeatureDetails',
{
defaultMessage: 'Delete cases and comments',
}
),
includeIn: 'all',
savedObject: {
all: [],
read: [],
},
cases: {
delete: [observabilityFeatureId],
},
ui: casesCapabilities.delete,
},
],
},
],
},
],
});
let annotationsApiPromise: Promise<AnnotationsAPI> | undefined;

View file

@ -6,7 +6,7 @@
*/
import type { TestCaseWithoutTimeline } from '../../objects/case';
import { ALL_CASES_NAME } from '../../screens/all_cases';
import { ALL_CASES_CREATE_NEW_CASE_BTN, ALL_CASES_NAME } from '../../screens/all_cases';
import { goToCreateNewCase } from '../../tasks/all_cases';
import { cleanKibana, deleteCases } from '../../tasks/common';
@ -31,12 +31,21 @@ import {
secAllUser,
secReadCasesAllUser,
secReadCasesAll,
secAllCasesNoDelete,
secAllCasesNoDeleteUser,
secAllCasesOnlyReadDeleteUser,
secAllCasesOnlyReadDelete,
} from '../../tasks/privileges';
import { CASES_URL } from '../../urls/navigation';
import { openSourcerer } from '../../tasks/sourcerer';
const usersToCreate = [secAllUser, secReadCasesAllUser];
const rolesToCreate = [secAll, secReadCasesAll];
const usersToCreate = [
secAllUser,
secReadCasesAllUser,
secAllCasesNoDeleteUser,
secAllCasesOnlyReadDeleteUser,
];
const rolesToCreate = [secAll, secReadCasesAll, secAllCasesNoDelete, secAllCasesOnlyReadDelete];
// needed to generate index pattern
const visitSecuritySolution = () => {
visitHostDetailsPage();
@ -51,6 +60,7 @@ const testCase: TestCaseWithoutTimeline = {
reporter: 'elastic',
owner: 'securitySolution',
};
describe('Cases privileges', () => {
before(() => {
cleanKibana();
@ -67,7 +77,7 @@ describe('Cases privileges', () => {
deleteCases();
});
for (const user of [secAllUser, secReadCasesAllUser]) {
for (const user of [secAllUser, secReadCasesAllUser, secAllCasesNoDeleteUser]) {
it(`User ${user.username} with role(s) ${user.roles.join()} can create a case`, () => {
loginWithUser(user);
visitWithUser(CASES_URL, user);
@ -80,4 +90,12 @@ describe('Cases privileges', () => {
cy.get(ALL_CASES_NAME).should('have.text', testCase.name);
});
}
for (const user of [secAllCasesOnlyReadDeleteUser]) {
it(`User ${user.username} with role(s) ${user.roles.join()} cannot create a case`, () => {
loginWithUser(user);
visitWithUser(CASES_URL, user);
cy.get(ALL_CASES_CREATE_NEW_CASE_BTN).should('not.exist');
});
}
});

View file

@ -110,6 +110,68 @@ export const secReadCasesAllUser: User = {
roles: [secReadCasesAll.name],
};
export const secAllCasesOnlyReadDelete: Role = {
name: 'sec_all_cases_only_read_delete',
privileges: {
elasticsearch: {
indices: [
{
names: ['*'],
privileges: ['all'],
},
],
},
kibana: [
{
feature: {
siem: ['all'],
securitySolutionCases: ['cases_read', 'cases_delete'],
actions: ['all'],
actionsSimulators: ['all'],
},
spaces: ['*'],
},
],
},
};
export const secAllCasesOnlyReadDeleteUser: User = {
username: 'sec_all_cases_only_read_delete_user',
password: 'password',
roles: [secAllCasesOnlyReadDelete.name],
};
export const secAllCasesNoDelete: Role = {
name: 'sec_all_cases_no_delete',
privileges: {
elasticsearch: {
indices: [
{
names: ['*'],
privileges: ['all'],
},
],
},
kibana: [
{
feature: {
siem: ['all'],
securitySolutionCases: ['minimal_all'],
actions: ['all'],
actionsSimulators: ['all'],
},
spaces: ['*'],
},
],
},
};
export const secAllCasesNoDeleteUser: User = {
username: 'sec_all_cases_no_delete_user',
password: 'password',
roles: [secAllCasesNoDelete.name],
};
const getUserInfo = (user: User): UserInfo => ({
username: user.username,
full_name: user.username.replace('_', ' '),

View file

@ -57,8 +57,7 @@ const StartAppComponent: FC<StartAppComponent> = ({
cases,
} = useKibana().services;
const [darkMode] = useUiSetting$<boolean>(DEFAULT_DARK_MODE);
const userPermissions = useGetUserCasesPermissions();
const casesPermissions = { all: userPermissions.crud, read: userPermissions.read };
const userCasesPermissions = useGetUserCasesPermissions();
const CasesContext = cases.ui.getCasesContext();
return (
<EuiErrorBoundary>
@ -71,7 +70,7 @@ const StartAppComponent: FC<StartAppComponent> = ({
<UserPrivilegesProvider kibanaCapabilities={capabilities}>
<ManageUserInfo>
<ReactQueryClientProvider>
<CasesContext owner={[APP_ID]} permissions={casesPermissions}>
<CasesContext owner={[APP_ID]} permissions={userCasesPermissions}>
<PageRouter
history={history}
onAppLeave={onAppLeave}

View file

@ -4,11 +4,12 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { getDeepLinks } from '.';
import { getDeepLinks, hasFeaturesCapability } from '.';
import type { AppDeepLink, Capabilities } from '@kbn/core/public';
import { SecurityPageName } from '../types';
import { mockGlobalState } from '../../common/mock';
import { CASES_FEATURE_ID, SERVER_APP_ID } from '../../../common/constants';
import { createCapabilities } from '../../common/links/test_utils';
const findDeepLink = (id: string, deepLinks: AppDeepLink[]): AppDeepLink | null =>
deepLinks.reduce((deepLinkFound: AppDeepLink | null, deepLink) => {
@ -28,6 +29,14 @@ const basicLicense = 'basic';
const platinumLicense = 'platinum';
describe('deepLinks', () => {
describe('hasFeaturesCapability', () => {
it('returns true when features is undefined', () => {
expect(
hasFeaturesCapability(undefined, createCapabilities({ siem: { show: true } }))
).toBeTruthy();
});
});
it('should return a all basic license deep links in the premium deep links', () => {
const basicLinks = getDeepLinks(mockGlobalState.app.enableExperimental, basicLicense);
const platinumLinks = getDeepLinks(mockGlobalState.app.enableExperimental, platinumLicense);
@ -68,7 +77,7 @@ describe('deepLinks', () => {
it('should return case links for basic license with only read_cases capabilities', () => {
const basicLinks = getDeepLinks(mockGlobalState.app.enableExperimental, basicLicense, {
[CASES_FEATURE_ID]: { read_cases: true, crud_cases: false },
[CASES_FEATURE_ID]: { read_cases: true },
[SERVER_APP_ID]: { show: true },
} as unknown as Capabilities);
expect(findDeepLink(SecurityPageName.case, basicLinks)).toBeTruthy();
@ -76,15 +85,21 @@ describe('deepLinks', () => {
it('should return case links with NO deepLinks for basic license with only read_cases capabilities', () => {
const basicLinks = getDeepLinks(mockGlobalState.app.enableExperimental, basicLicense, {
[CASES_FEATURE_ID]: { read_cases: true, crud_cases: false },
[CASES_FEATURE_ID]: { read_cases: true },
[SERVER_APP_ID]: { show: true },
} as unknown as Capabilities);
expect(findDeepLink(SecurityPageName.case, basicLinks)?.deepLinks?.length === 0).toBeTruthy();
});
it('should return case links with deepLinks for basic license with crud_cases capabilities', () => {
it('should return case links with deepLinks for basic license with permissive capabilities', () => {
const basicLinks = getDeepLinks(mockGlobalState.app.enableExperimental, basicLicense, {
[CASES_FEATURE_ID]: { read_cases: true, crud_cases: true },
[CASES_FEATURE_ID]: {
create_cases: true,
read_cases: true,
update_cases: true,
delete_cases: true,
push_cases: true,
},
[SERVER_APP_ID]: { show: true },
} as unknown as Capabilities);
@ -93,17 +108,29 @@ describe('deepLinks', () => {
).toBeTruthy();
});
it('should return case links with deepLinks for basic license with crud_cases capabilities and security disabled', () => {
it('should return case links with deepLinks for basic license with permissive capabilities and security disabled', () => {
const basicLinks = getDeepLinks(mockGlobalState.app.enableExperimental, platinumLicense, {
[CASES_FEATURE_ID]: { read_cases: true, crud_cases: true },
[CASES_FEATURE_ID]: {
create_cases: true,
read_cases: true,
update_cases: true,
delete_cases: true,
push_cases: true,
},
[SERVER_APP_ID]: { show: false },
} as unknown as Capabilities);
expect(findDeepLink(SecurityPageName.case, basicLinks)).toBeTruthy();
});
it('should return NO case links for basic license with NO read_cases capabilities', () => {
it('should return NO case links for basic license with NO cases capabilities', () => {
const basicLinks = getDeepLinks(mockGlobalState.app.enableExperimental, basicLicense, {
[CASES_FEATURE_ID]: { read_cases: false, crud_cases: false },
[CASES_FEATURE_ID]: {
create_cases: false,
read_cases: false,
update_cases: false,
delete_cases: false,
push_cases: false,
},
[SERVER_APP_ID]: { show: true },
} as unknown as Capabilities);
expect(findDeepLink(SecurityPageName.case, basicLinks)).toBeFalsy();

View file

@ -7,9 +7,15 @@
import { i18n } from '@kbn/i18n';
import { get } from 'lodash';
import type { LicenseType } from '@kbn/licensing-plugin/common/types';
import { getCasesDeepLinks } from '@kbn/cases-plugin/public';
import {
CREATE_CASES_CAPABILITY,
DELETE_CASES_CAPABILITY,
PUSH_CASES_CAPABILITY,
READ_CASES_CAPABILITY,
UPDATE_CASES_CAPABILITY,
} from '@kbn/cases-plugin/common';
import type { AppDeepLink, AppUpdater, Capabilities } from '@kbn/core/public';
import { AppNavLinkStatus } from '@kbn/core/public';
import type { Subject, Subscription } from 'rxjs';
@ -65,20 +71,36 @@ import {
RESPONSE_ACTIONS_PATH,
} from '../../../common/constants';
import type { ExperimentalFeatures } from '../../../common/experimental_features';
import { subscribeAppLinks } from '../../common/links';
import { hasCapabilities, subscribeAppLinks } from '../../common/links';
import type { AppLinkItems } from '../../common/links/types';
const FEATURE = {
export const FEATURE = {
general: `${SERVER_APP_ID}.show`,
casesRead: `${CASES_FEATURE_ID}.read_cases`,
casesCrud: `${CASES_FEATURE_ID}.crud_cases`,
casesCreate: `${CASES_FEATURE_ID}.${CREATE_CASES_CAPABILITY}`,
casesRead: `${CASES_FEATURE_ID}.${READ_CASES_CAPABILITY}`,
casesUpdate: `${CASES_FEATURE_ID}.${UPDATE_CASES_CAPABILITY}`,
casesDelete: `${CASES_FEATURE_ID}.${DELETE_CASES_CAPABILITY}`,
casesPush: `${CASES_FEATURE_ID}.${PUSH_CASES_CAPABILITY}`,
} as const;
type Feature = typeof FEATURE[keyof typeof FEATURE];
type FeatureKey = typeof FEATURE[keyof typeof FEATURE];
/**
* The format of defining features supports OR and AND mechanism. To specify features in an OR fashion
* they can be defined in a single level array like: [requiredFeature1, requiredFeature2]. If either of these features
* is satisfied the deeplinks would be included. To require that the features be AND'd together a second level array
* can be specified: [feature1, [feature2, feature3]] this would result in feature1 || (feature2 && feature3). To specify
* features that all must be and'd together an example would be: [[feature1, feature2]], this would result in the boolean
* operation feature1 && feature2.
*
* The final format is to specify a single feature, this would be like: features: feature1, which is the same as
* features: [feature1]
*/
type Features = FeatureKey | Array<FeatureKey | FeatureKey[]>;
type SecuritySolutionDeepLink = AppDeepLink & {
isPremium?: boolean;
features?: Feature[];
features?: Features;
/**
* Displays deep link when feature flag is enabled.
*/
@ -417,11 +439,11 @@ export const securitySolutionsDeepLinks: SecuritySolutionDeepLink[] = [
features: [FEATURE.casesRead],
},
[SecurityPageName.caseConfigure]: {
features: [FEATURE.casesCrud],
features: [FEATURE.casesUpdate],
isPremium: true,
},
[SecurityPageName.caseCreate]: {
features: [FEATURE.casesCrud],
features: [FEATURE.casesCreate],
},
},
}),
@ -525,14 +547,15 @@ export function getDeepLinks(
return filterDeepLinks(securitySolutionsDeepLinks);
}
function hasFeaturesCapability(
features: Feature[] | undefined,
export function hasFeaturesCapability(
features: Features | undefined,
capabilities: Capabilities
): boolean {
if (!features) {
return true;
}
return features.some((featureKey) => get(capabilities, featureKey, false));
return hasCapabilities(features, capabilities);
}
export function isPremiumLicense(licenseType?: LicenseType): boolean {

View file

@ -5,6 +5,11 @@
* 2.0.
*/
import {
CREATE_CASES_CAPABILITY,
READ_CASES_CAPABILITY,
UPDATE_CASES_CAPABILITY,
} from '@kbn/cases-plugin/common';
import { getCasesDeepLinks } from '@kbn/cases-plugin/public';
import { CASES_FEATURE_ID, CASES_PATH, SecurityPageName } from '../../common/constants';
import type { LinkItem } from '../common/links/types';
@ -16,16 +21,16 @@ export const getCasesLinkItems = (): LinkItem => {
[SecurityPageName.case]: {
globalNavEnabled: true,
globalNavOrder: 5,
capabilities: [`${CASES_FEATURE_ID}.read_cases`],
capabilities: [`${CASES_FEATURE_ID}.${READ_CASES_CAPABILITY}`],
},
[SecurityPageName.caseConfigure]: {
capabilities: [`${CASES_FEATURE_ID}.crud_cases`],
capabilities: [`${CASES_FEATURE_ID}.${UPDATE_CASES_CAPABILITY}`],
licenseType: 'gold',
sideNavDisabled: true,
hideTimeline: true,
},
[SecurityPageName.caseCreate]: {
capabilities: [`${CASES_FEATURE_ID}.crud_cases`],
capabilities: [`${CASES_FEATURE_ID}.${CREATE_CASES_CAPABILITY}`],
sideNavDisabled: true,
hideTimeline: true,
},

View file

@ -42,8 +42,7 @@ const TimelineDetailsPanel = () => {
const CaseContainerComponent: React.FC = () => {
const { cases } = useKibana().services;
const { getAppUrl, navigateTo } = useNavigation();
const userPermissions = useGetUserCasesPermissions();
const casesPermissions = { all: userPermissions.crud, read: userPermissions.read };
const userCasesPermissions = useGetUserCasesPermissions();
const dispatch = useDispatch();
const { formatUrl: detectionsFormatUrl, search: detectionsUrlSearch } = useFormatUrl(
SecurityPageName.rules
@ -145,7 +144,7 @@ const CaseContainerComponent: React.FC = () => {
},
},
useFetchAlertData,
permissions: casesPermissions,
permissions: userCasesPermissions,
})}
</CaseDetailsRefreshContext.Provider>
<SpyRoute pageName={SecurityPageName.case} />

View file

@ -0,0 +1,66 @@
/*
* 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.
*/
export const noCasesCapabilities = () => ({
create_cases: false,
read_cases: false,
update_cases: false,
delete_cases: false,
push_cases: false,
});
export const readCasesCapabilities = () => ({
create_cases: false,
read_cases: true,
update_cases: false,
delete_cases: false,
push_cases: false,
});
export const allCasesCapabilities = () => ({
create_cases: true,
read_cases: true,
update_cases: true,
delete_cases: true,
push_cases: true,
});
export const noCasesPermissions = () => ({
all: false,
create: false,
read: false,
update: false,
delete: false,
push: false,
});
export const readCasesPermissions = () => ({
all: false,
create: false,
read: true,
update: false,
delete: false,
push: false,
});
export const writeCasesPermissions = () => ({
all: false,
create: true,
read: false,
update: true,
delete: true,
push: true,
});
export const allCasesPermissions = () => ({
all: true,
create: true,
read: true,
update: true,
delete: true,
push: true,
});

View file

@ -12,6 +12,7 @@ import { TestProviders } from '../../mock';
import { useKibana as mockUseKibana } from '../../lib/kibana/__mocks__';
import { useGetUserCasesPermissions } from '../../lib/kibana';
import { RelatedCases } from './related_cases';
import { noCasesPermissions, readCasesPermissions } from '../../../cases_test_utils';
const mockedUseKibana = mockUseKibana();
const mockGetRelatedCases = jest.fn();
@ -42,9 +43,7 @@ const eventId = '1c84d9bff4884dabe6aa1bb15f08433463b848d9269e587078dc56669550d27
describe('Related Cases', () => {
describe('When user does not have cases read permissions', () => {
test('should not show related cases when user does not have permissions', () => {
(useGetUserCasesPermissions as jest.Mock).mockReturnValue({
read: false,
});
(useGetUserCasesPermissions as jest.Mock).mockReturnValue(noCasesPermissions());
render(
<TestProviders>
<RelatedCases eventId={eventId} />
@ -56,9 +55,7 @@ describe('Related Cases', () => {
});
describe('When user does have case read permissions', () => {
beforeEach(() => {
(useGetUserCasesPermissions as jest.Mock).mockReturnValue({
read: true,
});
(useGetUserCasesPermissions as jest.Mock).mockReturnValue(readCasesPermissions());
});
describe('When related cases are unable to be retrieved', () => {

View file

@ -25,11 +25,11 @@ export const RelatedCases: React.FC<Props> = React.memo(({ eventId, isReadOnly }
services: { cases },
} = useKibana();
const toasts = useToasts();
const casePermissions = useGetUserCasesPermissions();
const userCasesPermissions = useGetUserCasesPermissions();
const [relatedCases, setRelatedCases] = useState<RelatedCaseList>([]);
const [areCasesLoading, setAreCasesLoading] = useState(true);
const [hasError, setHasError] = useState<boolean>(false);
const hasCasesReadPermissions = casePermissions.read;
const hasCasesReadPermissions = userCasesPermissions.read;
const getRelatedCases = useCallback(async () => {
let relatedCaseList: RelatedCaseList = [];

View file

@ -20,6 +20,12 @@ import { useIsExperimentalFeatureEnabled } from '../../../hooks/use_experimental
import { TestProviders } from '../../../mock';
import { CASES_FEATURE_ID } from '../../../../../common/constants';
import { useCanSeeHostIsolationExceptionsMenu } from '../../../../management/pages/host_isolation_exceptions/view/hooks';
import {
noCasesPermissions,
readCasesCapabilities,
readCasesPermissions,
} from '../../../../cases_test_utils';
import { mockCasesContract } from '@kbn/cases-plugin/public/mocks';
jest.mock('../../../lib/kibana/kibana_react');
jest.mock('../../../lib/kibana');
@ -85,8 +91,12 @@ describe('useSecuritySolutionNavigation', () => {
(useRouteSpy as jest.Mock).mockReturnValue(mockRouteSpy);
(useCanSeeHostIsolationExceptionsMenu as jest.Mock).mockReturnValue(true);
const cases = mockCasesContract();
cases.helpers.getUICapabilities.mockReturnValue(readCasesPermissions());
(useKibana as jest.Mock).mockReturnValue({
services: {
cases,
application: {
navigateToApp: jest.fn(),
getUrlForApp: (appId: string, options?: { path?: string; deepLinkId?: boolean }) =>
@ -96,7 +106,7 @@ describe('useSecuritySolutionNavigation', () => {
show: true,
crud: true,
},
[CASES_FEATURE_ID]: { read_cases: true, crud_cases: false },
[CASES_FEATURE_ID]: readCasesCapabilities(),
},
},
chrome: {
@ -154,10 +164,7 @@ describe('useSecuritySolutionNavigation', () => {
describe('Permission gated routes', () => {
describe('cases', () => {
it('should display the cases navigation item when the user has read permissions', () => {
(useGetUserCasesPermissions as jest.Mock).mockReturnValue({
crud: true,
read: true,
});
(useGetUserCasesPermissions as jest.Mock).mockReturnValue(readCasesPermissions());
const { result } = renderHook<{}, KibanaPageTemplateProps['solutionNav']>(
() => useSecuritySolutionNavigation(),
@ -182,10 +189,7 @@ describe('useSecuritySolutionNavigation', () => {
});
it('should not display the cases navigation item when the user does not have read permissions', () => {
(useGetUserCasesPermissions as jest.Mock).mockReturnValue({
crud: false,
read: false,
});
(useGetUserCasesPermissions as jest.Mock).mockReturnValue(noCasesPermissions());
const { result } = renderHook<{}, KibanaPageTemplateProps['solutionNav']>(
() => useSecuritySolutionNavigation(),

View file

@ -23,6 +23,7 @@ import { cloneDeep } from 'lodash';
import { useKibana } from '../../lib/kibana/kibana_react';
import { CASES_FEATURE_ID } from '../../../../common/constants';
import { mockCasesContract } from '@kbn/cases-plugin/public/mocks';
import { allCasesCapabilities, allCasesPermissions } from '../../../cases_test_utils';
jest.mock('react-router-dom', () => {
const actual = jest.requireActual('react-router-dom');
return {
@ -70,6 +71,9 @@ describe('VisualizationActions', () => {
beforeEach(() => {
jest.clearAllMocks();
const cases = mockCasesContract();
cases.helpers.getUICapabilities.mockReturnValue(allCasesPermissions());
(useKibana as jest.Mock).mockReturnValue({
services: {
lens: {
@ -88,7 +92,7 @@ describe('VisualizationActions', () => {
},
},
application: {
capabilities: { [CASES_FEATURE_ID]: { crud_cases: true, read_cases: true } },
capabilities: { [CASES_FEATURE_ID]: allCasesCapabilities() },
getUrlForApp: jest.fn(),
navigateToApp: jest.fn(),
},

View file

@ -9,6 +9,11 @@ import { useKibana as mockUseKibana } from '../../lib/kibana/__mocks__';
import { kpiHostMetricLensAttributes } from './lens_attributes/hosts/kpi_host_metric';
import { useAddToExistingCase } from './use_add_to_existing_case';
import { useGetUserCasesPermissions } from '../../lib/kibana';
import {
allCasesPermissions,
readCasesPermissions,
writeCasesPermissions,
} from '../../../cases_test_utils';
const mockedUseKibana = mockUseKibana();
const mockGetUseCasesAddToExistingCaseModal = jest.fn();
@ -41,10 +46,7 @@ describe('useAddToExistingCase', () => {
};
beforeEach(() => {
(useGetUserCasesPermissions as jest.Mock).mockReturnValue({
crud: true,
read: true,
});
(useGetUserCasesPermissions as jest.Mock).mockReturnValue(allCasesPermissions());
});
it('getUseCasesAddToExistingCaseModal with attachments', () => {
@ -62,11 +64,21 @@ describe('useAddToExistingCase', () => {
expect(result.current.disabled).toEqual(false);
});
it("button disabled if user Can't Crud", () => {
(useGetUserCasesPermissions as jest.Mock).mockReturnValue({
crud: false,
read: true,
});
it("disables the button if the user can't create but can read", () => {
(useGetUserCasesPermissions as jest.Mock).mockReturnValue(readCasesPermissions());
const { result } = renderHook(() =>
useAddToExistingCase({
lensAttributes: kpiHostMetricLensAttributes,
timeRange,
onAddToCaseClicked: mockOnAddToCaseClicked,
})
);
expect(result.current.disabled).toEqual(true);
});
it("disables the button if the user can't read but can create", () => {
(useGetUserCasesPermissions as jest.Mock).mockReturnValue(writeCasesPermissions());
const { result } = renderHook(() =>
useAddToExistingCase({

View file

@ -24,7 +24,7 @@ export const useAddToExistingCase = ({
lensAttributes: LensAttributes | null;
timeRange: { from: string; to: string } | null;
}) => {
const userPermissions = useGetUserCasesPermissions();
const userCasesPermissions = useGetUserCasesPermissions();
const { cases } = useKibana().services;
const attachments = useMemo(() => {
return [
@ -53,6 +53,10 @@ export const useAddToExistingCase = ({
return {
onAddToExistingCaseClicked,
disabled: lensAttributes == null || timeRange == null || !userPermissions.crud,
disabled:
lensAttributes == null ||
timeRange == null ||
!userCasesPermissions.create ||
!userCasesPermissions.read,
};
};

View file

@ -9,6 +9,11 @@ import { useKibana as mockUseKibana } from '../../lib/kibana/__mocks__';
import { kpiHostMetricLensAttributes } from './lens_attributes/hosts/kpi_host_metric';
import { useAddToNewCase } from './use_add_to_new_case';
import { useGetUserCasesPermissions } from '../../lib/kibana';
import {
allCasesPermissions,
readCasesPermissions,
writeCasesPermissions,
} from '../../../cases_test_utils';
jest.mock('../../lib/kibana/kibana_react');
@ -41,10 +46,7 @@ describe('useAddToNewCase', () => {
to: '2022-03-07T15:59:59.999Z',
};
beforeEach(() => {
(useGetUserCasesPermissions as jest.Mock).mockReturnValue({
crud: true,
read: true,
});
(useGetUserCasesPermissions as jest.Mock).mockReturnValue(allCasesPermissions());
});
it('getUseCasesAddToNewCaseFlyout with attachments', () => {
@ -60,11 +62,20 @@ describe('useAddToNewCase', () => {
expect(result.current.disabled).toEqual(false);
});
it("button disabled if user Can't Crud", () => {
(useGetUserCasesPermissions as jest.Mock).mockReturnValue({
crud: false,
read: true,
});
it("disables the button if the user can't create but can read", () => {
(useGetUserCasesPermissions as jest.Mock).mockReturnValue(readCasesPermissions());
const { result } = renderHook(() =>
useAddToNewCase({
lensAttributes: kpiHostMetricLensAttributes,
timeRange,
})
);
expect(result.current.disabled).toEqual(true);
});
it("disables the button if the user can't read but can create", () => {
(useGetUserCasesPermissions as jest.Mock).mockReturnValue(writeCasesPermissions());
const { result } = renderHook(() =>
useAddToNewCase({

View file

@ -23,7 +23,7 @@ export interface UseAddToNewCaseProps {
const owner = APP_ID;
export const useAddToNewCase = ({ onClick, timeRange, lensAttributes }: UseAddToNewCaseProps) => {
const userPermissions = useGetUserCasesPermissions();
const userCasesPermissions = useGetUserCasesPermissions();
const { cases } = useKibana().services;
const attachments = useMemo(() => {
return [
@ -52,6 +52,10 @@ export const useAddToNewCase = ({ onClick, timeRange, lensAttributes }: UseAddTo
return {
onAddToNewCaseClicked,
disabled: lensAttributes == null || timeRange == null || !userPermissions.crud,
disabled:
lensAttributes == null ||
timeRange == null ||
!userCasesPermissions.create ||
!userCasesPermissions.read,
};
};

View file

@ -14,6 +14,7 @@ import { camelCase, isArray, isObject } from 'lodash';
import { set } from '@elastic/safer-lodash-set';
import type { AuthenticatedUser } from '@kbn/security-plugin/common/model';
import type { NavigateToAppOptions } from '@kbn/core/public';
import type { CasesPermissions } from '@kbn/cases-plugin/common/ui';
import {
APP_UI_ID,
CASES_FEATURE_ID,
@ -146,24 +147,37 @@ export const useCurrentUser = (): AuthenticatedElasticUser | null => {
return user;
};
export interface UseGetUserCasesPermissions {
crud: boolean;
read: boolean;
}
export const useGetUserCasesPermissions = () => {
const [casesPermissions, setCasesPermissions] = useState<UseGetUserCasesPermissions>({
crud: false,
const [casesPermissions, setCasesPermissions] = useState<CasesPermissions>({
all: false,
create: false,
read: false,
update: false,
delete: false,
push: false,
});
const uiCapabilities = useKibana().services.application.capabilities;
const casesCapabilities = useKibana().services.cases.helpers.getUICapabilities(
uiCapabilities[CASES_FEATURE_ID]
);
useEffect(() => {
setCasesPermissions({
crud: !!uiCapabilities[CASES_FEATURE_ID]?.crud_cases,
read: !!uiCapabilities[CASES_FEATURE_ID]?.read_cases,
all: casesCapabilities.all,
create: casesCapabilities.create,
read: casesCapabilities.read,
update: casesCapabilities.update,
delete: casesCapabilities.delete,
push: casesCapabilities.push,
});
}, [uiCapabilities]);
}, [
casesCapabilities.all,
casesCapabilities.create,
casesCapabilities.read,
casesCapabilities.update,
casesCapabilities.delete,
casesCapabilities.push,
]);
return casesPermissions;
};

View file

@ -40,6 +40,8 @@ import { MlLocatorDefinition } from '@kbn/ml-plugin/public';
import type { EuiTheme } from '@kbn/kibana-react-plugin/common';
import { MockUrlService } from '@kbn/share-plugin/common/mocks';
import { fleetMock } from '@kbn/fleet-plugin/public/mocks';
import { mockCasesContract } from '@kbn/cases-plugin/public/mocks';
import { noCasesPermissions } from '../../../cases_test_utils';
import { triggersActionsUiMock } from '@kbn/triggers-actions-ui-plugin/public/mocks';
const mockUiSettings: Record<string, unknown> = {
@ -98,17 +100,13 @@ export const createStartServicesMock = (
const locator = urlService.locators.create(new MlLocatorDefinition());
const fleet = fleetMock.createStartMock();
const unifiedSearch = unifiedSearchPluginMock.createStartContract();
const cases = mockCasesContract();
cases.helpers.getUICapabilities.mockReturnValue(noCasesPermissions());
const triggersActionsUi = triggersActionsUiMock.createStart();
return {
...core,
cases: {
getAllCases: jest.fn(),
getCaseView: jest.fn(),
getConfigureCases: jest.fn(),
getCreateCase: jest.fn(),
getRecentCases: jest.fn(),
},
cases,
unifiedSearch,
data: {
...data,

View file

@ -18,7 +18,9 @@ import {
needsUrlState,
updateAppLinks,
useLinkExists,
hasCapabilities,
} from './links';
import { createCapabilities } from './test_utils';
const defaultAppLinks: AppLinkItems = [
{
@ -288,4 +290,118 @@ describe('Security app links', () => {
});
});
});
describe('hasCapabilities', () => {
const siemShow = 'siem.show';
const createCases = 'securitySolutionCases.create_cases';
const readCases = 'securitySolutionCases.read_cases';
const pushCases = 'securitySolutionCases.push_cases';
it('returns false when capabilities is an empty array', () => {
expect(hasCapabilities([], createCapabilities())).toBeFalsy();
});
it('returns true when the capability requested is specified as a single value', () => {
expect(hasCapabilities(siemShow, createCapabilities({ siem: { show: true } }))).toBeTruthy();
});
it('returns true when the capability requested is a single entry in an array', () => {
expect(
hasCapabilities([siemShow], createCapabilities({ siem: { show: true } }))
).toBeTruthy();
});
it("returns true when the capability requested is a single entry in an AND'd array format", () => {
expect(
hasCapabilities([[siemShow]], createCapabilities({ siem: { show: true } }))
).toBeTruthy();
});
it('returns true when only one requested capability is found in an OR situation', () => {
expect(
hasCapabilities(
[siemShow, createCases],
createCapabilities({
siem: { show: true },
securitySolutionCases: { create_cases: false },
})
)
).toBeTruthy();
});
it('returns true when only the create_cases requested capability is found in an OR situation', () => {
expect(
hasCapabilities(
[siemShow, createCases],
createCapabilities({
siem: { show: false },
securitySolutionCases: { create_cases: true },
})
)
).toBeTruthy();
});
it('returns false when none of the requested capabilities are found in an OR situation', () => {
expect(
hasCapabilities(
[readCases, createCases],
createCapabilities({
siem: { show: true },
securitySolutionCases: { create_cases: false },
})
)
).toBeFalsy();
});
it('returns true when all of the requested capabilities are found in an AND situation', () => {
expect(
hasCapabilities(
[[readCases, createCases]],
createCapabilities({
siem: { show: true },
securitySolutionCases: { read_cases: true, create_cases: true },
})
)
).toBeTruthy();
});
it('returns false when neither the single OR capability is found nor all of the AND capabilities', () => {
expect(
hasCapabilities(
[siemShow, [readCases, createCases]],
createCapabilities({
siem: { show: false },
securitySolutionCases: { read_cases: false, create_cases: true },
})
)
).toBeFalsy();
});
it('returns true when the single OR capability is found when using an OR with an AND format', () => {
expect(
hasCapabilities(
[siemShow, [readCases, createCases]],
createCapabilities({
siem: { show: true },
securitySolutionCases: { read_cases: false, create_cases: true },
})
)
).toBeTruthy();
});
it("returns false when the AND'd expressions are not satisfied", () => {
expect(
hasCapabilities(
[
[siemShow, pushCases],
[readCases, createCases],
],
createCapabilities({
siem: { show: true },
securitySolutionCases: { read_cases: false, create_cases: true, push_cases: false },
})
)
).toBeFalsy();
});
});
});

View file

@ -6,7 +6,7 @@
*/
import type { Capabilities } from '@kbn/core/public';
import { get } from 'lodash';
import { get, isArray } from 'lodash';
import { useEffect, useState } from 'react';
import { BehaviorSubject } from 'rxjs';
import type { SecurityPageName } from '../../../common/constants';
@ -173,9 +173,35 @@ const getFilteredAppLinks = (
return acc;
}, []);
/**
* The format of defining features supports OR and AND mechanism. To specify features in an OR fashion
* they can be defined in a single level array like: [requiredFeature1, requiredFeature2]. If either of these features
* is satisfied the links would be included. To require that the features be AND'd together a second level array
* can be specified: [feature1, [feature2, feature3]] this would result in feature1 || (feature2 && feature3).
*
* The final format is to specify a single feature, this would be like: features: feature1, which is the same as
* features: [feature1]
*/
type LinkCapabilities = string | Array<string | string[]>;
// It checks if the user has at least one of the link capabilities needed
const hasCapabilities = (linkCapabilities: string[], userCapabilities: Capabilities): boolean =>
linkCapabilities.some((linkCapability) => get(userCapabilities, linkCapability, false));
export const hasCapabilities = <T>(
linkCapabilities: LinkCapabilities,
userCapabilities: Capabilities
): boolean => {
if (!isArray(linkCapabilities)) {
return !!get(userCapabilities, linkCapabilities, false);
} else {
return linkCapabilities.some((linkCapabilityKeyOr) => {
if (isArray(linkCapabilityKeyOr)) {
return linkCapabilityKeyOr.every((linkCapabilityKeyAnd) =>
get(userCapabilities, linkCapabilityKeyAnd, false)
);
}
return get(userCapabilities, linkCapabilityKeyOr, false);
});
}
};
const isLinkAllowed = (
link: LinkItem,

View file

@ -0,0 +1,21 @@
/*
* 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 { Capabilities } from '@kbn/core/public';
interface FeatureCap {
[key: string]: Record<string, boolean | Record<string, boolean>>;
}
export const createCapabilities = (capabilities?: FeatureCap): Capabilities => {
return {
navLinks: {},
management: {},
catalogue: {},
...capabilities,
};
};

View file

@ -38,9 +38,18 @@ export interface LinkItem {
experimentalKey?: keyof ExperimentalFeatures;
/**
* Capabilities strings (using object dot notation) to enable the link.
* Uses "or" conditional, only one enabled capability is needed to activate the link
*
* The format of defining features supports OR and AND mechanism. To specify features in an OR fashion
* they can be defined in a single level array like: [requiredFeature1, requiredFeature2]. If either of these features
* is satisfied the deeplinks would be included. To require that the features be AND'd together a second level array
* can be specified: [feature1, [feature2, feature3]] this would result in feature1 || (feature2 && feature3). To specify
* features that all must be and'd together an example would be: [[feature1, feature2]], this would result in the boolean
* operation feature1 && feature2.
*
* The final format is to specify a single feature, this would be like: features: feature1, which is the same as
* features: [feature1]
*/
capabilities?: string[];
capabilities?: string | Array<string | string[]>;
/**
* Categories to display in the navigation
*/

View file

@ -63,8 +63,12 @@ jest.mock('../../../../common/lib/kibana', () => ({
},
}),
useGetUserCasesPermissions: jest.fn().mockReturnValue({
crud: true,
all: true,
create: true,
read: true,
update: true,
delete: true,
push: true,
}),
}));

View file

@ -34,8 +34,7 @@ export const useAddToCaseActions = ({
timelineId,
}: UseAddToCaseActions) => {
const { cases: casesUi } = useKibana().services;
const casePermissions = useGetUserCasesPermissions();
const hasWritePermissions = casePermissions.crud;
const userCasesPermissions = useGetUserCasesPermissions();
const isAlert = useMemo(() => {
return ecsData?.event?.kind?.includes('signal');
@ -84,7 +83,8 @@ export const useAddToCaseActions = ({
TimelineId.detectionsRulesDetailsPage,
TimelineId.active,
].includes(timelineId as TimelineId) &&
hasWritePermissions &&
userCasesPermissions.create &&
userCasesPermissions.read &&
isAlert
) {
return [
@ -113,7 +113,8 @@ export const useAddToCaseActions = ({
ariaLabel,
handleAddToExistingCaseClick,
handleAddToNewCaseClick,
hasWritePermissions,
userCasesPermissions.create,
userCasesPermissions.read,
timelineId,
isAlert,
]);

View file

@ -19,8 +19,7 @@ export interface UseAddToCaseActions {
export const useBulkAddToCaseActions = ({ onClose, onSuccess }: UseAddToCaseActions = {}) => {
const { cases: casesUi } = useKibana().services;
const casePermissions = useGetUserCasesPermissions();
const hasWritePermissions = casePermissions.crud;
const userCasesPermissions = useGetUserCasesPermissions();
const createCaseFlyout = casesUi.hooks.getUseCasesAddToNewCaseFlyout({
onClose,
@ -32,7 +31,7 @@ export const useBulkAddToCaseActions = ({ onClose, onSuccess }: UseAddToCaseActi
});
return useMemo(() => {
return hasWritePermissions
return userCasesPermissions.create && userCasesPermissions.read
? [
{
label: ADD_TO_NEW_CASE,
@ -58,5 +57,11 @@ export const useBulkAddToCaseActions = ({ onClose, onSuccess }: UseAddToCaseActi
},
]
: [];
}, [casesUi.helpers, createCaseFlyout, hasWritePermissions, selectCaseModal]);
}, [
casesUi.helpers,
createCaseFlyout,
userCasesPermissions.create,
userCasesPermissions.read,
selectCaseModal,
]);
};

View file

@ -35,6 +35,7 @@ import {
} from '../../../common/utils/endpoint_alert_check';
import { HostStatus } from '../../../../common/endpoint/types';
import { getUserPrivilegesMockDefaultValue } from '../../../common/components/user_privileges/__mocks__';
import { allCasesPermissions } from '../../../cases_test_utils';
jest.mock('../../../common/components/user_privileges');
@ -43,7 +44,7 @@ jest.mock('../user_info', () => ({
}));
jest.mock('../../../common/lib/kibana');
(useGetUserCasesPermissions as jest.Mock).mockReturnValue({ crud: true });
(useGetUserCasesPermissions as jest.Mock).mockReturnValue(allCasesPermissions());
jest.mock('../../containers/detection_engine/alerts/use_alerts_privileges', () => ({
useAlertsPrivileges: jest.fn().mockReturnValue({ hasIndexWrite: true, hasKibanaCRUD: true }),

View file

@ -18,6 +18,11 @@ import {
getField,
} from './helpers';
import type { StartedSubPlugins } from './types';
import {
allCasesCapabilities,
noCasesCapabilities,
readCasesCapabilities,
} from './cases_test_utils';
describe('public helpers parseRoute', () => {
it('should properly parse hash route', () => {
@ -76,7 +81,7 @@ describe('#getSubPluginRoutesByCapabilities', () => {
it('cases routes should return NoPrivilegesPage component when cases plugin is NOT available ', () => {
const routes = getSubPluginRoutesByCapabilities(mockSubPlugins, {
[SERVER_APP_ID]: { show: true, crud: false },
[CASES_FEATURE_ID]: { read_cases: false, crud_cases: false },
[CASES_FEATURE_ID]: noCasesCapabilities(),
} as unknown as Capabilities);
const casesRoute = routes.find((r) => r.path === 'cases');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -91,7 +96,7 @@ describe('#getSubPluginRoutesByCapabilities', () => {
it('alerts should return NoPrivilegesPage component when siem plugin is NOT available ', () => {
const routes = getSubPluginRoutesByCapabilities(mockSubPlugins, {
[SERVER_APP_ID]: { show: false, crud: false },
[CASES_FEATURE_ID]: { read_cases: true, crud_cases: false },
[CASES_FEATURE_ID]: readCasesCapabilities(),
} as unknown as Capabilities);
const alertsRoute = routes.find((r) => r.path === 'alerts');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -106,7 +111,7 @@ describe('#getSubPluginRoutesByCapabilities', () => {
it('should return NoPrivilegesPage for each route when both plugins are NOT available ', () => {
const routes = getSubPluginRoutesByCapabilities(mockSubPlugins, {
[SERVER_APP_ID]: { show: false, crud: false },
[CASES_FEATURE_ID]: { read_cases: false, crud_cases: false },
[CASES_FEATURE_ID]: noCasesCapabilities(),
} as unknown as Capabilities);
const casesRoute = routes.find((r) => r.path === 'cases');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -134,7 +139,7 @@ describe('#isSubPluginAvailable', () => {
expect(
isSubPluginAvailable('pluginKey', {
[SERVER_APP_ID]: { show: true, crud: true },
[CASES_FEATURE_ID]: { read_cases: false, crud_cases: false },
[CASES_FEATURE_ID]: noCasesCapabilities(),
} as unknown as Capabilities)
).toBeTruthy();
});
@ -143,7 +148,7 @@ describe('#isSubPluginAvailable', () => {
expect(
isSubPluginAvailable('pluginKey', {
[SERVER_APP_ID]: { show: true, crud: false },
[CASES_FEATURE_ID]: { read_cases: false, crud_cases: false },
[CASES_FEATURE_ID]: noCasesCapabilities(),
} as unknown as Capabilities)
).toBeTruthy();
});
@ -152,7 +157,7 @@ describe('#isSubPluginAvailable', () => {
expect(
isSubPluginAvailable('pluginKey', {
[SERVER_APP_ID]: { show: false, crud: false },
[CASES_FEATURE_ID]: { read_cases: false, crud_cases: false },
[CASES_FEATURE_ID]: noCasesCapabilities(),
} as unknown as Capabilities)
).toBeFalsy();
});
@ -161,7 +166,7 @@ describe('#isSubPluginAvailable', () => {
expect(
isSubPluginAvailable('cases', {
[SERVER_APP_ID]: { show: false, crud: false },
[CASES_FEATURE_ID]: { read_cases: true, crud_cases: true },
[CASES_FEATURE_ID]: allCasesCapabilities(),
} as unknown as Capabilities)
).toBeTruthy();
});
@ -170,7 +175,7 @@ describe('#isSubPluginAvailable', () => {
expect(
isSubPluginAvailable('cases', {
[SERVER_APP_ID]: { show: false, crud: false },
[CASES_FEATURE_ID]: { read_cases: true, crud_cases: false },
[CASES_FEATURE_ID]: readCasesCapabilities(),
} as unknown as Capabilities)
).toBeTruthy();
});
@ -179,7 +184,7 @@ describe('#isSubPluginAvailable', () => {
expect(
isSubPluginAvailable('pluginKey', {
[SERVER_APP_ID]: { show: false, crud: false },
[CASES_FEATURE_ID]: { read_cases: false, crud_cases: false },
[CASES_FEATURE_ID]: noCasesCapabilities(),
} as unknown as Capabilities)
).toBeFalsy();
});
@ -189,7 +194,7 @@ describe('RedirectRoute', () => {
it('RedirectRoute should redirect to overview page when siem and case privileges are all', () => {
const mockCapabilitities = {
[SERVER_APP_ID]: { show: true, crud: true },
[CASES_FEATURE_ID]: { read_cases: true, crud_cases: true },
[CASES_FEATURE_ID]: allCasesCapabilities(),
} as unknown as Capabilities;
expect(shallow(<RedirectRoute capabilities={mockCapabilitities} />)).toMatchInlineSnapshot(`
<Redirect
@ -201,7 +206,7 @@ describe('RedirectRoute', () => {
it('RedirectRoute should redirect to overview page when siem and case privileges are read', () => {
const mockCapabilitities = {
[SERVER_APP_ID]: { show: true, crud: false },
[CASES_FEATURE_ID]: { read_cases: true, crud_cases: false },
[CASES_FEATURE_ID]: readCasesCapabilities(),
} as unknown as Capabilities;
expect(shallow(<RedirectRoute capabilities={mockCapabilitities} />)).toMatchInlineSnapshot(`
<Redirect
@ -213,7 +218,7 @@ describe('RedirectRoute', () => {
it('RedirectRoute should redirect to overview page when siem and case privileges are off', () => {
const mockCapabilitities = {
[SERVER_APP_ID]: { show: false, crud: false },
[CASES_FEATURE_ID]: { read_cases: false, crud_cases: false },
[CASES_FEATURE_ID]: noCasesCapabilities(),
} as unknown as Capabilities;
expect(shallow(<RedirectRoute capabilities={mockCapabilitities} />)).toMatchInlineSnapshot(`
<Redirect
@ -225,7 +230,7 @@ describe('RedirectRoute', () => {
it('RedirectRoute should redirect to overview page when siem privilege is read and case privilege is all', () => {
const mockCapabilitities = {
[SERVER_APP_ID]: { show: true, crud: false },
[CASES_FEATURE_ID]: { read_cases: true, crud_cases: true },
[CASES_FEATURE_ID]: allCasesCapabilities(),
} as unknown as Capabilities;
expect(shallow(<RedirectRoute capabilities={mockCapabilitities} />)).toMatchInlineSnapshot(`
<Redirect
@ -237,7 +242,7 @@ describe('RedirectRoute', () => {
it('RedirectRoute should redirect to overview page when siem privilege is read and case privilege is read', () => {
const mockCapabilitities = {
[SERVER_APP_ID]: { show: true, crud: false },
[CASES_FEATURE_ID]: { read_cases: true, crud_cases: true },
[CASES_FEATURE_ID]: allCasesCapabilities(),
} as unknown as Capabilities;
expect(shallow(<RedirectRoute capabilities={mockCapabilitities} />)).toMatchInlineSnapshot(`
<Redirect
@ -249,7 +254,7 @@ describe('RedirectRoute', () => {
it('RedirectRoute should redirect to cases page when siem privilege is none and case privilege is read', () => {
const mockCapabilitities = {
[SERVER_APP_ID]: { show: false, crud: false },
[CASES_FEATURE_ID]: { read_cases: true, crud_cases: false },
[CASES_FEATURE_ID]: readCasesCapabilities(),
} as unknown as Capabilities;
expect(shallow(<RedirectRoute capabilities={mockCapabilitities} />)).toMatchInlineSnapshot(`
<Redirect
@ -261,7 +266,7 @@ describe('RedirectRoute', () => {
it('RedirectRoute should redirect to cases page when siem privilege is none and case privilege is all', () => {
const mockCapabilitities = {
[SERVER_APP_ID]: { show: false, crud: false },
[CASES_FEATURE_ID]: { read_cases: true, crud_cases: true },
[CASES_FEATURE_ID]: allCasesCapabilities(),
} as unknown as Capabilities;
expect(shallow(<RedirectRoute capabilities={mockCapabilitities} />)).toMatchInlineSnapshot(`
<Redirect

Some files were not shown because too many files have changed in this diff Show more