From d02cd07d2cee87ede2fa6fb365100c78cbe4db4d Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 14 Sep 2022 16:55:20 +0300 Subject: [PATCH] [Cases] Case assignment license (#140508) * [ResponseOps][Cases] Assign users on cases sidebar section (#138108) * Add reusable user profile selector component * Refactoring services, auth * Adding suggest api and tests * Move to package and add examples * [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' * Working integration tests * Switching suggest api tags * Adding assignees field * Adding tests for size and owner * Add server side example * CI Fixes * Adding assignee integration tests * fix tests * Addd tests * Starting user actions changes * Adding api tag tests * Addressing feedback * Using lodash for array comparison logic and tests * Fixing type error * Create suggest query * Adding assignees user action * [ResponseOps][Cases] Refactoring client args and authentication (#137345) * Refactoring services, auth * Fixing type errors * Adding assignees migration and tests * Fixing types and added more tests * Fixing cypress test * Fixing test * Add tests * Add security as dependency and fix types * Add bulk get profiles query * Rename folder * Addressed suggestions from code review * Fix types * Adding migration for assignees field and tests * Adding comments and a few more tests * Updating comments and spelling * Revert security solution optional * PR feedback * Updated user avatar component * Reduce size * Make security required * Fix tests * Addressing feedback * Do not retry suggestions * Assign users to a case * Restructure components * Move assignees to case view * Show assigned users * Refactoring bulk get and display name * Adding tests for user tooltip * Adding tests * Hovering and tests * Fixing errors * Some pr clean up * Fixing tests and adding more * Adding functional tests * Fixing jest test failure * Passing in current user profile * Refactoring assignment with useEffect * Fixing icon alignment and removal render bug * Fixing type errors * Fixing test errors * Adding bulk get privs and tests * Fixing popover tests * Handling unknown users * Adding tests for unknown users * Adding wait for popover calls * Addressing design feedback * Addressing remaining feedback * Refactoring css and name prop * Refactoring popover * Refactoring search component * Addressing some feedback * Adjusting sorting * Fixing tests * Fixing type error * Fixing test error * Fixing test errors * Removing display name Co-authored-by: Thom Heymann Co-authored-by: Jonathan Buttner Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Thom Heymann <190132+thomheymann@users.noreply.github.com> Co-authored-by: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> * [ResponseOps][Cases] Add assignee user actions (#139392) * Adding user actions for assignees * Fixing merge issues * Fixing test mock errors * Fixing display name errors and themselves * Fixing test for time being * Addressing feedback * Addressing comma and uniq feedback * Using core getUserDisplayName * Fixing import and removing flickering * Fixing tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> * Detect when user is typing * Use select to tranform data * [Cases] Assign users when creating a case (#139754) * Init * Render users * Assign yourself * Add tests * Fix tests * PR feedback * [ResponseOps][Cases] Filter by assignees (#139441) * Starting the filtering * Rough draft working for assignees filtering * Adding integration tests for new route * Starting to write tests * Fixing tests * Cleaning up more tests * Removing duplicate call for current user * Fixing type errors and tests * Adding tests for filtering * Adding rbac tests * Fixing translations * Fixing api integration tests * Fixing severity tests * Really fixing arrays equal * Fixing ml tests and refactoring find assignees * Fixing cypress tests * Fixing types * Fix tests * Addressing first round of feedback * Reverting the recent cases changes * Fixing tests * Fixing more tests and types * Allowing multi select * Fixing attachment framework issue * Addressing feedback * Fixing type error * Fixing tests * Sort users and improve loading * Fix ml security dependecies * Fix permissions when fetching suggestions * Fixing read permissions and suggest api * Hiding assignee delete icon * Hiding the assign yourself text when undefined * Show licensing callout to the all cases page * Hide assignees column * Hide assignees & connectors column * Renames * Check licensing when assigning users to a case * Hide assinee from create case page * Hide assignee filtering * Check license when filtering by assignees * Add UI tests & fixes * Add integration tests * Fix tests * Fix i18n * Revert core changes * PR feedback and fix tests * PR feedback Co-authored-by: Thom Heymann Co-authored-by: Jonathan Buttner Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Thom Heymann <190132+thomheymann@users.noreply.github.com> Co-authored-by: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> --- x-pack/plugins/cases/kibana.json | 1 + .../common/lib/kibana/kibana_react.mock.ts | 16 +- .../public/common/mock/test_providers.tsx | 22 ++- .../use_cases_features.test.tsx | 38 +++- .../use_cases_features.tsx | 15 +- .../cases/public/common/use_license.test.tsx | 181 ++++++++++++++++++ .../cases/public/common/use_license.tsx | 44 +++++ .../all_cases/all_cases_list.test.tsx | 72 +++++++ .../components/all_cases/columns.test.tsx | 4 + .../public/components/all_cases/columns.tsx | 32 ++-- .../components/all_cases/index.test.tsx | 7 + .../public/components/all_cases/index.tsx | 2 + .../all_cases/table_filters.test.tsx | 33 ++++ .../components/all_cases/table_filters.tsx | 16 +- .../callouts/case_callouts.test.tsx | 41 ++++ .../components/callouts/case_callouts.tsx | 30 +++ .../callouts/platinum_callout.test.tsx | 26 +++ .../components/callouts/platinum_callout.tsx | 47 +++++ .../components/callouts/translations.ts | 20 ++ .../components/case_action_bar/index.tsx | 2 +- .../case_view/case_view_page.test.tsx | 13 +- .../case_view/components/assign_users.tsx | 2 +- .../components/case_view_activity.test.tsx | 66 ++++++- .../components/case_view_activity.tsx | 26 ++- .../case_view/metrics/index.test.tsx | 4 +- .../components/case_view/metrics/index.tsx | 2 +- .../public/components/create/form.test.tsx | 27 +++ .../cases/public/components/create/form.tsx | 14 +- .../components/create/form_context.test.tsx | 19 ++ .../public/components/create/form_context.tsx | 2 +- .../public/components/create/index.test.tsx | 2 +- .../cases/public/components/create/title.tsx | 2 - .../components/edit_connector/index.tsx | 1 + .../public/components/user_actions/index.tsx | 2 +- x-pack/plugins/cases/public/types.ts | 2 + .../cases/server/client/cases/create.ts | 16 +- .../plugins/cases/server/client/cases/find.ts | 17 +- .../cases/server/client/cases/update.ts | 31 ++- x-pack/plugins/cases/server/client/factory.ts | 6 + x-pack/plugins/cases/server/client/types.ts | 2 + x-pack/plugins/cases/server/plugin.ts | 3 + .../cases/server/services/licensing.ts | 38 ++++ x-pack/plugins/ml/public/application/app.tsx | 3 +- .../application/license/check_license.tsx | 8 +- x-pack/plugins/ml/public/plugin.ts | 5 +- .../tests/basic/cases/assignees.ts | 72 +++++++ .../security_and_spaces/tests/basic/index.ts | 1 + .../security_and_spaces/tests/common/index.ts | 1 - .../user_actions/get_all_user_actions.ts | 31 --- .../{common => trial}/cases/assignees.ts | 0 .../user_actions/get_all_user_actions.ts | 35 +++- .../security_and_spaces/tests/trial/index.ts | 1 + .../fixtures/plugins/cases/kibana.json | 2 +- 53 files changed, 993 insertions(+), 112 deletions(-) rename x-pack/plugins/cases/public/{components/cases_context => common}/use_cases_features.test.tsx (60%) rename x-pack/plugins/cases/public/{components/cases_context => common}/use_cases_features.tsx (70%) create mode 100644 x-pack/plugins/cases/public/common/use_license.test.tsx create mode 100644 x-pack/plugins/cases/public/common/use_license.tsx create mode 100644 x-pack/plugins/cases/public/components/callouts/case_callouts.test.tsx create mode 100644 x-pack/plugins/cases/public/components/callouts/case_callouts.tsx create mode 100644 x-pack/plugins/cases/public/components/callouts/platinum_callout.test.tsx create mode 100644 x-pack/plugins/cases/public/components/callouts/platinum_callout.tsx create mode 100644 x-pack/plugins/cases/public/components/callouts/translations.ts create mode 100644 x-pack/plugins/cases/server/services/licensing.ts create mode 100644 x-pack/test/cases_api_integration/security_and_spaces/tests/basic/cases/assignees.ts rename x-pack/test/cases_api_integration/security_and_spaces/tests/{common => trial}/cases/assignees.ts (100%) diff --git a/x-pack/plugins/cases/kibana.json b/x-pack/plugins/cases/kibana.json index d027023b7c96a..6d21904177a47 100644 --- a/x-pack/plugins/cases/kibana.json +++ b/x-pack/plugins/cases/kibana.json @@ -24,6 +24,7 @@ "embeddable", "esUiShared", "lens", + "licensing", "features", "kibanaReact", "kibanaUtils", diff --git a/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.ts b/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.ts index 6e279a7798cf1..792379af1b532 100644 --- a/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.ts +++ b/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.ts @@ -8,21 +8,29 @@ /* eslint-disable react/display-name */ import React from 'react'; +import { BehaviorSubject } from 'rxjs'; import { PublicAppInfo } from '@kbn/core/public'; import { RecursivePartial } from '@elastic/eui/src/components/common'; import { coreMock } from '@kbn/core/public/mocks'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { ILicense } from '@kbn/licensing-plugin/public'; import { StartServices } from '../../../types'; import { EuiTheme } from '@kbn/kibana-react-plugin/common'; import { securityMock } from '@kbn/security-plugin/public/mocks'; import { spacesPluginMock } from '@kbn/spaces-plugin/public/mocks'; import { triggersActionsUiMock } from '@kbn/triggers-actions-ui-plugin/public/mocks'; -import { BehaviorSubject } from 'rxjs'; +import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; import { registerConnectorsToMockActionRegistry } from '../../mock/register_connectors'; import { connectorsMock } from '../../mock/connectors'; -export const createStartServicesMock = (): StartServices => { +interface StartServiceArgs { + license?: ILicense | null; +} + +export const createStartServicesMock = ({ license }: StartServiceArgs = {}): StartServices => { + const licensingPluginMock = licensingMock.createStart(); + const services = { ...coreMock.createStart(), storage: { ...coreMock.createStorage(), get: jest.fn(), set: jest.fn(), remove: jest.fn() }, @@ -33,6 +41,10 @@ export const createStartServicesMock = (): StartServices => { security: securityMock.createStart(), triggersActionsUi: triggersActionsUiMock.createStart(), spaces: spacesPluginMock.createStartContract(), + licensing: + license != null + ? { ...licensingPluginMock, license$: new BehaviorSubject(license) } + : licensingPluginMock, } as unknown as StartServices; services.application.currentAppId$ = new BehaviorSubject('testAppId'); diff --git a/x-pack/plugins/cases/public/common/mock/test_providers.tsx b/x-pack/plugins/cases/public/common/mock/test_providers.tsx index a180fb942bd15..571825a21474b 100644 --- a/x-pack/plugins/cases/public/common/mock/test_providers.tsx +++ b/x-pack/plugins/cases/public/common/mock/test_providers.tsx @@ -11,17 +11,15 @@ import React from 'react'; import { euiDarkVars } from '@kbn/ui-theme'; import { I18nProvider } from '@kbn/i18n-react'; import { ThemeProvider } from 'styled-components'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render as reactRender, RenderOptions, RenderResult } from '@testing-library/react'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ILicense } from '@kbn/licensing-plugin/public'; import { SECURITY_SOLUTION_OWNER } from '../../../common/constants'; import { CasesFeatures, CasesPermissions } from '../../../common/ui/types'; import { CasesProvider } from '../../components/cases_context'; -import { - createKibanaContextProviderMock, - createStartServicesMock, -} from '../lib/kibana/kibana_react.mock'; +import { createStartServicesMock } from '../lib/kibana/kibana_react.mock'; import { FieldHook } from '../shared_imports'; import { StartServices } from '../../types'; import { ReleasePhase } from '../../components/types'; @@ -37,11 +35,11 @@ interface TestProviderProps { releasePhase?: ReleasePhase; externalReferenceAttachmentTypeRegistry?: ExternalReferenceAttachmentTypeRegistry; persistableStateAttachmentTypeRegistry?: PersistableStateAttachmentTypeRegistry; + license?: ILicense; } type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResult; window.scrollTo = jest.fn(); -const MockKibanaContextProvider = createKibanaContextProviderMock(); /** A utility for wrapping children in the providers required to run most tests */ const TestProvidersComponent: React.FC = ({ @@ -52,6 +50,7 @@ const TestProvidersComponent: React.FC = ({ releasePhase = 'ga', externalReferenceAttachmentTypeRegistry = new ExternalReferenceAttachmentTypeRegistry(), persistableStateAttachmentTypeRegistry = new PersistableStateAttachmentTypeRegistry(), + license, }) => { const queryClient = new QueryClient({ defaultOptions: { @@ -66,9 +65,11 @@ const TestProvidersComponent: React.FC = ({ }, }); + const services = createStartServicesMock({ license }); + return ( - + ({ eui: euiDarkVars, darkMode: true })}> = ({ - + ); }; @@ -100,6 +101,7 @@ export interface AppMockRenderer { queryClient: QueryClient; AppWrapper: React.FC<{ children: React.ReactElement }>; } + export const testQueryClient = new QueryClient({ defaultOptions: { queries: { @@ -124,8 +126,10 @@ export const createAppMockRenderer = ({ releasePhase = 'ga', externalReferenceAttachmentTypeRegistry = new ExternalReferenceAttachmentTypeRegistry(), persistableStateAttachmentTypeRegistry = new PersistableStateAttachmentTypeRegistry(), + license, }: Omit = {}): AppMockRenderer => { - const services = createStartServicesMock(); + const services = createStartServicesMock({ license }); + const queryClient = new QueryClient({ defaultOptions: { queries: { diff --git a/x-pack/plugins/cases/public/components/cases_context/use_cases_features.test.tsx b/x-pack/plugins/cases/public/common/use_cases_features.test.tsx similarity index 60% rename from x-pack/plugins/cases/public/components/cases_context/use_cases_features.test.tsx rename to x-pack/plugins/cases/public/common/use_cases_features.test.tsx index 22c39b525107d..e64bc61acf91d 100644 --- a/x-pack/plugins/cases/public/components/cases_context/use_cases_features.test.tsx +++ b/x-pack/plugins/cases/public/common/use_cases_features.test.tsx @@ -7,9 +7,11 @@ import React from 'react'; import { renderHook } from '@testing-library/react-hooks'; +import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; +import { CasesContextFeatures } from '../../common/ui'; import { useCasesFeatures, UseCasesFeatures } from './use_cases_features'; -import { TestProviders } from '../../common/mock'; -import { CasesContextFeatures } from '../../containers/types'; +import { TestProviders } from './mock/test_providers'; +import { LicenseType, LICENSE_TYPE } from '@kbn/licensing-plugin/common/types'; describe('useCasesFeatures', () => { // isAlertsEnabled, isSyncAlertsEnabled, alerts @@ -40,6 +42,8 @@ describe('useCasesFeatures', () => { isAlertsEnabled, isSyncAlertsEnabled, metricsFeatures: [], + caseAssignmentAuthorized: false, + pushToServiceAuthorized: false, }); } ); @@ -55,6 +59,36 @@ describe('useCasesFeatures', () => { isAlertsEnabled: true, isSyncAlertsEnabled: true, metricsFeatures: ['connectors'], + caseAssignmentAuthorized: false, + pushToServiceAuthorized: false, }); }); + + const licenseTests: Array<[LicenseType, boolean]> = (Object.keys(LICENSE_TYPE) as LicenseType[]) + .filter((type: LicenseType) => isNaN(Number(type))) + .map((type) => [ + type, + type === 'platinum' || type === 'enterprise' || type === 'trial' ? true : false, + ]); + + it.each(licenseTests)( + 'allows platinum features on a platinum license', + async (type, expectedResult) => { + const license = licensingMock.createLicense({ + license: { type }, + }); + + const { result } = renderHook<{}, UseCasesFeatures>(() => useCasesFeatures(), { + wrapper: ({ children }) => {children}, + }); + + expect(result.current).toEqual({ + isAlertsEnabled: true, + isSyncAlertsEnabled: true, + metricsFeatures: [], + caseAssignmentAuthorized: expectedResult, + pushToServiceAuthorized: expectedResult, + }); + } + ); }); diff --git a/x-pack/plugins/cases/public/components/cases_context/use_cases_features.tsx b/x-pack/plugins/cases/public/common/use_cases_features.tsx similarity index 70% rename from x-pack/plugins/cases/public/components/cases_context/use_cases_features.tsx rename to x-pack/plugins/cases/public/common/use_cases_features.tsx index b0316b5ff9665..064fe89fb90df 100644 --- a/x-pack/plugins/cases/public/components/cases_context/use_cases_features.tsx +++ b/x-pack/plugins/cases/public/common/use_cases_features.tsx @@ -6,17 +6,23 @@ */ import { useMemo } from 'react'; -import { SingleCaseMetricsFeature } from '../../containers/types'; -import { useCasesContext } from './use_cases_context'; +import { SingleCaseMetricsFeature } from '../../common/ui'; +import { useCasesContext } from '../components/cases_context/use_cases_context'; +import { useLicense } from './use_license'; export interface UseCasesFeatures { isAlertsEnabled: boolean; isSyncAlertsEnabled: boolean; + caseAssignmentAuthorized: boolean; + pushToServiceAuthorized: boolean; metricsFeatures: SingleCaseMetricsFeature[]; } export const useCasesFeatures = (): UseCasesFeatures => { const { features } = useCasesContext(); + const { isAtLeastPlatinum } = useLicense(); + const hasLicenseGreaterThanPlatinum = isAtLeastPlatinum(); + const casesFeatures = useMemo( () => ({ isAlertsEnabled: features.alerts.enabled, @@ -30,8 +36,11 @@ export const useCasesFeatures = (): UseCasesFeatures => { */ isSyncAlertsEnabled: !features.alerts.enabled ? false : features.alerts.sync, metricsFeatures: features.metrics, + caseAssignmentAuthorized: hasLicenseGreaterThanPlatinum, + pushToServiceAuthorized: hasLicenseGreaterThanPlatinum, }), - [features.alerts.enabled, features.alerts.sync, features.metrics] + [features.alerts.enabled, features.alerts.sync, features.metrics, hasLicenseGreaterThanPlatinum] ); + return casesFeatures; }; diff --git a/x-pack/plugins/cases/public/common/use_license.test.tsx b/x-pack/plugins/cases/public/common/use_license.test.tsx new file mode 100644 index 0000000000000..588abc83306bb --- /dev/null +++ b/x-pack/plugins/cases/public/common/use_license.test.tsx @@ -0,0 +1,181 @@ +/* + * 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 { licensingMock } from '@kbn/licensing-plugin/public/mocks'; +import { renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from './mock'; +import { useLicense } from './use_license'; + +describe('useLicense', () => { + describe('isAtLeast', () => { + it('returns true on a valid basic license', () => { + const { result } = renderHook( + () => { + return useLicense(); + }, + { wrapper: ({ children }) => {children} } + ); + + expect(result.current.isAtLeast('basic')).toBeTruthy(); + }); + + it('returns false on a valid basic license', () => { + const { result } = renderHook( + () => { + return useLicense(); + }, + { wrapper: ({ children }) => {children} } + ); + + expect(result.current.isAtLeast('platinum')).toBeFalsy(); + }); + + it('returns false if the license is not available', async () => { + const license = licensingMock.createLicense(); + // @ts-expect-error: we need to change the isAvailable to test the hook + license.isAvailable = false; + + const { result } = renderHook( + () => { + return useLicense(); + }, + { wrapper: ({ children }) => {children} } + ); + + expect(result.current.isAtLeast('basic')).toBeFalsy(); + }); + + it('returns false if the license is not valid', async () => { + const license = licensingMock.createLicense({ + license: { status: 'invalid' }, + }); + + const { result } = renderHook( + () => { + return useLicense(); + }, + { wrapper: ({ children }) => {children} } + ); + + expect(result.current.isAtLeast('basic')).toBeFalsy(); + }); + }); + + describe('isAtLeastPlatinum', () => { + it('returns true on a valid platinum license', () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + + const { result } = renderHook( + () => { + return useLicense(); + }, + { wrapper: ({ children }) => {children} } + ); + + expect(result.current.isAtLeastPlatinum()).toBeTruthy(); + }); + + it('returns false on a valid platinum license', () => { + const license = licensingMock.createLicense({ + license: { type: 'gold' }, + }); + + const { result } = renderHook( + () => { + return useLicense(); + }, + { wrapper: ({ children }) => {children} } + ); + + expect(result.current.isAtLeastPlatinum()).toBeFalsy(); + }); + }); + + describe('isAtLeastGold', () => { + it('returns true on a valid gold license', () => { + const license = licensingMock.createLicense({ + license: { type: 'gold' }, + }); + + const { result } = renderHook( + () => { + return useLicense(); + }, + { wrapper: ({ children }) => {children} } + ); + + expect(result.current.isAtLeastGold()).toBeTruthy(); + }); + + it('returns false on a valid gold license', () => { + const license = licensingMock.createLicense({ + license: { type: 'basic' }, + }); + + const { result } = renderHook( + () => { + return useLicense(); + }, + { wrapper: ({ children }) => {children} } + ); + + expect(result.current.isAtLeastGold()).toBeFalsy(); + }); + }); + + describe('isAtLeastEnterprise', () => { + it('returns true on a valid enterprise license', () => { + const license = licensingMock.createLicense({ + license: { type: 'enterprise' }, + }); + + const { result } = renderHook( + () => { + return useLicense(); + }, + { wrapper: ({ children }) => {children} } + ); + + expect(result.current.isAtLeastEnterprise()).toBeTruthy(); + }); + + it('returns false on a valid enterprise license', () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + + const { result } = renderHook( + () => { + return useLicense(); + }, + { wrapper: ({ children }) => {children} } + ); + + expect(result.current.isAtLeastEnterprise()).toBeFalsy(); + }); + }); + + describe('getLicense', () => { + it('returns the license', () => { + const license = licensingMock.createLicense({ + license: { type: 'enterprise' }, + }); + + const { result } = renderHook( + () => { + return useLicense(); + }, + { wrapper: ({ children }) => {children} } + ); + + expect(result.current.getLicense()).toEqual(license); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/common/use_license.tsx b/x-pack/plugins/cases/public/common/use_license.tsx new file mode 100644 index 0000000000000..f8bdddf2bf956 --- /dev/null +++ b/x-pack/plugins/cases/public/common/use_license.tsx @@ -0,0 +1,44 @@ +/* + * 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 { ILicense, LicenseType } from '@kbn/licensing-plugin/public'; +import { useCallback } from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { Observable } from 'rxjs'; +import { useKibana } from './lib/kibana'; + +interface UseLicenseReturnValue { + isAtLeast: (level: LicenseType) => boolean; + isAtLeastPlatinum: () => boolean; + isAtLeastGold: () => boolean; + isAtLeastEnterprise: () => boolean; + getLicense: () => ILicense | null | undefined; +} + +export const useLicense = (): UseLicenseReturnValue => { + const { licensing } = useKibana().services; + const license = useObservable(licensing?.license$ ?? new Observable(), null); + + const isAtLeast = useCallback( + (level: LicenseType): boolean => { + return !!license && license.isAvailable && license.isActive && license.hasAtLeast(level); + }, + [license] + ); + + const isAtLeastPlatinum = useCallback(() => isAtLeast('platinum'), [isAtLeast]); + const isAtLeastGold = useCallback(() => isAtLeast('gold'), [isAtLeast]); + const isAtLeastEnterprise = useCallback(() => isAtLeast('enterprise'), [isAtLeast]); + + return { + isAtLeast, + isAtLeastPlatinum, + isAtLeastGold, + isAtLeastEnterprise, + getLicense: () => license, + }; +}; diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx index 5a77eb7155b86..a504bf251ff20 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx @@ -45,6 +45,7 @@ import { useGetCases } from '../../containers/use_get_cases'; import { useGetCurrentUserProfile } from '../../containers/user_profiles/use_get_current_user_profile'; import { userProfiles, userProfilesMap } from '../../containers/user_profiles/api.mock'; import { useBulkGetUserProfiles } from '../../containers/user_profiles/use_bulk_get_user_profiles'; +import { useLicense } from '../../common/use_license'; jest.mock('../../containers/use_create_attachments'); jest.mock('../../containers/use_bulk_update_case'); @@ -63,6 +64,7 @@ jest.mock('../app/use_available_owners', () => ({ useAvailableCasesOwners: () => ['securitySolution', 'observability'], })); jest.mock('../../containers/use_update_case'); +jest.mock('../../common/use_license'); const useDeleteCasesMock = useDeleteCases as jest.Mock; const useGetCasesMock = useGetCases as jest.Mock; @@ -76,6 +78,7 @@ const useKibanaMock = useKibana as jest.MockedFunction; const useGetConnectorsMock = useGetConnectors as jest.Mock; const useCreateAttachmentsMock = useCreateAttachments as jest.Mock; const useUpdateCaseMock = useUpdateCase as jest.Mock; +const useLicenseMock = useLicense as jest.Mock; const mockTriggersActionsUiService = triggersActionsUiMock.createStart(); @@ -174,11 +177,14 @@ describe('AllCasesListGeneric', () => { useBulkGetUserProfilesMock.mockReturnValue({ data: userProfilesMap }); useGetConnectorsMock.mockImplementation(() => ({ data: connectorsMock, isLoading: false })); useUpdateCaseMock.mockReturnValue({ updateCaseProperty }); + useLicenseMock.mockReturnValue({ isAtLeastPlatinum: () => false }); mockKibana(); moment.tz.setDefault('UTC'); }); it('should render AllCasesList', async () => { + useLicenseMock.mockReturnValue({ isAtLeastPlatinum: () => true }); + const wrapper = mount( @@ -217,6 +223,8 @@ describe('AllCasesListGeneric', () => { }); it("should show a tooltip with the assignee's email when hover over the assignee avatar", async () => { + useLicenseMock.mockReturnValue({ isAtLeastPlatinum: () => true }); + const result = render( @@ -1003,3 +1011,67 @@ describe('AllCasesListGeneric', () => { }); }); }); + +describe('Assignees', () => { + it('should hide the assignees column on basic license', async () => { + useLicenseMock.mockReturnValue({ isAtLeastPlatinum: () => false }); + + const result = render( + + + + ); + + await waitFor(() => { + expect(result.getByTestId('cases-table')).toBeTruthy(); + expect(result.queryAllByTestId('case-table-column-assignee').length).toBe(0); + }); + }); + + it('should show the assignees column on platinum license', async () => { + useLicenseMock.mockReturnValue({ isAtLeastPlatinum: () => true }); + + const result = render( + + + + ); + + await waitFor(() => { + expect(result.getByTestId('cases-table')).toBeTruthy(); + expect(result.queryAllByTestId('case-table-column-assignee').length).toBeGreaterThan(0); + }); + }); + + it('should hide the assignees filters on basic license', async () => { + useLicenseMock.mockReturnValue({ isAtLeastPlatinum: () => false }); + + const result = render( + + + + ); + + await waitFor(() => { + expect(result.getByTestId('cases-table')).toBeTruthy(); + expect(result.queryAllByTestId('options-filter-popover-button-assignees').length).toBe(0); + }); + }); + + it('should show the assignees filters on platinum license', async () => { + useLicenseMock.mockReturnValue({ isAtLeastPlatinum: () => true }); + + const result = render( + + + + ); + + await waitFor(() => { + expect(result.getByTestId('cases-table')).toBeTruthy(); + expect( + result.queryAllByTestId('options-filter-popover-button-assignees').length + ).toBeGreaterThan(0); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx b/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx index b09eecbb31f4f..7dec0d7937913 100644 --- a/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx @@ -16,6 +16,7 @@ import { AppMockRenderer, createAppMockRenderer, TestProviders } from '../../com describe('ExternalServiceColumn ', () => { let appMockRender: AppMockRenderer; + beforeEach(() => { jest.clearAllMocks(); appMockRender = createAppMockRenderer(); @@ -30,6 +31,7 @@ describe('ExternalServiceColumn ', () => { /> ); + expect( wrapper.find(`[data-test-subj="case-table-column-external-notPushed"]`).last().exists() ).toBeTruthy(); @@ -44,6 +46,7 @@ describe('ExternalServiceColumn ', () => { /> ); + expect( wrapper.find(`[data-test-subj="case-table-column-external-upToDate"]`).last().exists() ).toBeTruthy(); @@ -58,6 +61,7 @@ describe('ExternalServiceColumn ', () => { /> ); + expect( wrapper.find(`[data-test-subj="case-table-column-external-requiresUpdate"]`).last().exists() ).toBeTruthy(); diff --git a/x-pack/plugins/cases/public/components/all_cases/columns.tsx b/x-pack/plugins/cases/public/components/all_cases/columns.tsx index b12771c6ca375..00e9fb077589a 100644 --- a/x-pack/plugins/cases/public/components/all_cases/columns.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/columns.tsx @@ -40,7 +40,6 @@ import { StatusContextMenu } from '../case_action_bar/status_context_menu'; import { TruncatedText } from '../truncated_text'; import { getConnectorIcon } from '../utils'; import type { CasesOwners } from '../../client/helpers/can_use_cases'; -import { useCasesFeatures } from '../cases_context/use_cases_features'; import { severities } from '../severity/config'; import { useUpdateCase } from '../../containers/use_update_case'; import { useCasesContext } from '../cases_context/use_cases_context'; @@ -49,6 +48,7 @@ import { useAssignees } from '../../containers/user_profiles/use_assignees'; import { getUsernameDataTestSubj } from '../user_profiles/data_test_subject'; import { CurrentUserProfile } from '../types'; import { SmallUserAvatar } from '../user_profiles/small_user_avatar'; +import { useCasesFeatures } from '../../common/use_cases_features'; export type CasesColumns = | EuiTableActionsColumnType @@ -96,7 +96,9 @@ const AssigneesColumn: React.FC<{ ); }; + AssigneesColumn.displayName = 'AssigneesColumn'; + export interface GetCasesColumn { filterStatus: string; userProfiles: Map; @@ -130,7 +132,7 @@ export const useCasesColumns = ({ isLoading: isDeleting, } = useDeleteCases(); - const { isAlertsEnabled } = useCasesFeatures(); + const { isAlertsEnabled, caseAssignmentAuthorized } = useCasesFeatures(); const { permissions } = useCasesContext(); const [deleteThisCase, setDeleteThisCase] = useState({ @@ -216,17 +218,21 @@ export const useCasesColumns = ({ return getEmptyTagValue(); }, }, - { - field: 'assignees', - name: i18n.ASSIGNEES, - render: (assignees: Case['assignees']) => ( - - ), - }, + ...(caseAssignmentAuthorized + ? [ + { + field: 'assignees', + name: i18n.ASSIGNEES, + render: (assignees: Case['assignees']) => ( + + ), + }, + ] + : []), { field: 'tags', name: i18n.TAGS, diff --git a/x-pack/plugins/cases/public/components/all_cases/index.test.tsx b/x-pack/plugins/cases/public/components/all_cases/index.test.tsx index 38f0b9d53b1d1..a6a7f8e3a1f1e 100644 --- a/x-pack/plugins/cases/public/components/all_cases/index.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/index.test.tsx @@ -231,4 +231,11 @@ describe('AllCases', () => { ).toBeFalsy(); }); }); + + it('should render the case callouts', async () => { + const result = appMockRender.render(); + await waitFor(() => { + expect(result.getByTestId('case-callouts')).toBeInTheDocument(); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/all_cases/index.tsx b/x-pack/plugins/cases/public/components/all_cases/index.tsx index af467a7293239..6ed8e3f00f048 100644 --- a/x-pack/plugins/cases/public/components/all_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/index.tsx @@ -8,6 +8,7 @@ import React, { useMemo } from 'react'; import { CasesDeepLinkId } from '../../common/navigation'; import { useGetActionLicense } from '../../containers/use_get_action_license'; +import { CaseCallouts } from '../callouts/case_callouts'; import { useCasesBreadcrumbs } from '../use_breadcrumbs'; import { getActionLicenseError } from '../use_push_to_service/helpers'; import { AllCasesList } from './all_cases_list'; @@ -21,6 +22,7 @@ export const AllCases: React.FC = () => { return ( <> + diff --git a/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx b/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx index 2d6f4076f6cf3..3bb718a469f99 100644 --- a/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx @@ -10,6 +10,7 @@ import { mount } from 'enzyme'; import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; +import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; import { CaseStatuses } from '../../../common/api'; import { OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER } from '../../../common/constants'; @@ -41,6 +42,7 @@ const props = { describe('CasesTableFilters ', () => { let appMockRender: AppMockRenderer; + beforeEach(() => { appMockRender = createAppMockRenderer(); jest.clearAllMocks(); @@ -85,6 +87,12 @@ describe('CasesTableFilters ', () => { }); it('should call onFilterChange when selected assignees change', async () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + + appMockRender = createAppMockRenderer({ license }); + const { getByTestId, getByText } = appMockRender.render(); userEvent.click(getByTestId('options-filter-popover-button-assignees')); await waitForEuiPopoverOpen(); @@ -164,6 +172,12 @@ describe('CasesTableFilters ', () => { }, }; + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + + appMockRender = createAppMockRenderer({ license }); + appMockRender.render(); userEvent.click(screen.getByTestId('options-filter-popover-button-assignees')); await waitForEuiPopoverOpen(); @@ -330,4 +344,23 @@ describe('CasesTableFilters ', () => { expect(onCreateCasePressed).toHaveBeenCalledWith(); }); }); + + describe('assignees filter', () => { + it('should hide the assignees filters on basic license', async () => { + const result = appMockRender.render(); + + expect(result.queryByTestId('options-filter-popover-button-assignees')).toBeNull(); + }); + + it('should show the assignees filters on platinum license', async () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + + appMockRender = createAppMockRenderer({ license }); + const result = appMockRender.render(); + + expect(result.getByTestId('options-filter-popover-button-assignees')).toBeInTheDocument(); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/all_cases/table_filters.tsx b/x-pack/plugins/cases/public/components/all_cases/table_filters.tsx index d35ff00058b99..5ab587ad31759 100644 --- a/x-pack/plugins/cases/public/components/all_cases/table_filters.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/table_filters.tsx @@ -23,6 +23,7 @@ import { CASE_LIST_CACHE_KEY } from '../../containers/constants'; import { DEFAULT_FILTER_OPTIONS } from '../../containers/use_get_cases'; import { AssigneesFilterPopover } from './assignees_filter'; import { CurrentUserProfile } from '../types'; +import { useCasesFeatures } from '../../common/use_cases_features'; interface CasesTableFiltersProps { countClosedCases: number | null; @@ -71,6 +72,7 @@ const CasesTableFiltersComponent = ({ const [selectedOwner, setSelectedOwner] = useState([]); const [selectedAssignees, setSelectedAssignees] = useState([]); const { data: tags = [], refetch: fetchTags } = useGetTags(CASE_LIST_CACHE_KEY); + const { caseAssignmentAuthorized } = useCasesFeatures(); const refetch = useCallback(() => { fetchTags(); @@ -194,12 +196,14 @@ const CasesTableFiltersComponent = ({ - + {caseAssignmentAuthorized ? ( + + ) : null} { + let appMockRender: AppMockRenderer; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders', () => { + const result = appMockRender.render(); + expect(result.getByTestId('case-callouts')).toBeInTheDocument(); + }); + + it('shows the platinum license callout if the user has less than platinum license', () => { + const result = appMockRender.render(); + expect(result.getByTestId('case-callout-license-info')).toBeInTheDocument(); + }); + + it('does not show the platinum license callout if the user has platinum license', () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + + appMockRender = createAppMockRenderer({ license }); + const result = appMockRender.render(); + + expect(result.queryByTestId('case-callout-license-info')).toBeNull(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/callouts/case_callouts.tsx b/x-pack/plugins/cases/public/components/callouts/case_callouts.tsx new file mode 100644 index 0000000000000..5f03bb69d3ce4 --- /dev/null +++ b/x-pack/plugins/cases/public/components/callouts/case_callouts.tsx @@ -0,0 +1,30 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui'; +import React from 'react'; +import { useLicense } from '../../common/use_license'; +import { PlatinumLicenseCallout } from './platinum_callout'; + +const CaseCalloutsComponent: React.FC = () => { + const { euiTheme } = useEuiTheme(); + const { isAtLeastPlatinum } = useLicense(); + + return ( + + {!isAtLeastPlatinum() ? : null} + + ); +}; + +CaseCalloutsComponent.displayName = 'CaseCalloutsComponent'; + +export const CaseCallouts = React.memo(CaseCalloutsComponent); diff --git a/x-pack/plugins/cases/public/components/callouts/platinum_callout.test.tsx b/x-pack/plugins/cases/public/components/callouts/platinum_callout.test.tsx new file mode 100644 index 0000000000000..bad098a7562d4 --- /dev/null +++ b/x-pack/plugins/cases/public/components/callouts/platinum_callout.test.tsx @@ -0,0 +1,26 @@ +/* + * 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, createAppMockRenderer } from '../../common/mock'; +import { PlatinumLicenseCallout } from './platinum_callout'; + +describe('PlatinumLicenseCallout ', () => { + let appMockRender: AppMockRenderer; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders', () => { + const result = appMockRender.render(); + + expect(result.getByTestId('case-callout-license-info')).toBeInTheDocument(); + expect(result.getByText('Upgrade to an appropriate license')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/callouts/platinum_callout.tsx b/x-pack/plugins/cases/public/components/callouts/platinum_callout.tsx new file mode 100644 index 0000000000000..33ef7af0d80be --- /dev/null +++ b/x-pack/plugins/cases/public/components/callouts/platinum_callout.tsx @@ -0,0 +1,47 @@ +/* + * 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 { EuiCallOut, EuiLink } from '@elastic/eui'; +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import * as i18n from './translations'; + +const PlatinumLicenseCalloutComponent: React.FC = () => { + return ( + + + {i18n.LINK_APPROPRIATE_LICENSE} + + ), + cloud: ( + + {i18n.LINK_CLOUD_DEPLOYMENT} + + ), + }} + /> + + ); +}; + +PlatinumLicenseCalloutComponent.displayName = 'PlatinumLicenseCalloutComponent'; + +export const PlatinumLicenseCallout = React.memo(PlatinumLicenseCalloutComponent); diff --git a/x-pack/plugins/cases/public/components/callouts/translations.ts b/x-pack/plugins/cases/public/components/callouts/translations.ts new file mode 100644 index 0000000000000..168d0c06e4caf --- /dev/null +++ b/x-pack/plugins/cases/public/components/callouts/translations.ts @@ -0,0 +1,20 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const UPGRADE_TO_PLATINUM = i18n.translate('xpack.cases.callout.updateToPlatinumTitle', { + defaultMessage: 'Upgrade to an appropriate license', +}); + +export const LINK_APPROPRIATE_LICENSE = i18n.translate('xpack.cases.callout.appropriateLicense', { + defaultMessage: 'appropriate license', +}); + +export const LINK_CLOUD_DEPLOYMENT = i18n.translate('xpack.cases.callout.cloudDeploymentLink', { + defaultMessage: 'cloud deployment', +}); diff --git a/x-pack/plugins/cases/public/components/case_action_bar/index.tsx b/x-pack/plugins/cases/public/components/case_action_bar/index.tsx index 36d0a0152171f..09c59d0d6cc39 100644 --- a/x-pack/plugins/cases/public/components/case_action_bar/index.tsx +++ b/x-pack/plugins/cases/public/components/case_action_bar/index.tsx @@ -25,11 +25,11 @@ import { useGetCaseUserActions } from '../../containers/use_get_case_user_action import { StatusContextMenu } from './status_context_menu'; import { SyncAlertsSwitch } from '../case_settings/sync_alerts_switch'; import type { OnUpdateFields } from '../case_view/types'; -import { useCasesFeatures } from '../cases_context/use_cases_features'; import { FormattedRelativePreferenceDate } from '../formatted_date'; import { getStatusDate, getStatusTitle } from './helpers'; import { useRefreshCaseViewPage } from '../case_view/use_on_refresh_case_view_page'; import { useCasesContext } from '../cases_context/use_cases_context'; +import { useCasesFeatures } from '../../common/use_cases_features'; const MyDescriptionList = styled(EuiDescriptionList)` ${({ theme }) => css` diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx index 9e6f8c9246b2d..8719ef2662954 100644 --- a/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx @@ -33,6 +33,7 @@ import { } from './mocks'; import { CaseViewPageProps, CASE_VIEW_PAGE_TABS } from './types'; import { userProfiles } from '../../containers/user_profiles/api.mock'; +import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; jest.mock('../../containers/use_get_action_license'); jest.mock('../../containers/use_update_case'); @@ -101,8 +102,10 @@ describe('CaseViewPage', () => { useGetConnectorsMock.mockReturnValue({ data: connectorsMock, isLoading: false }); useGetTagsMock.mockReturnValue({ data: [], isLoading: false }); useBulkGetUserProfilesMock.mockReturnValue({ data: new Map(), isLoading: false }); - - appMockRenderer = createAppMockRenderer(); + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + appMockRenderer = createAppMockRenderer({ license }); }); it('should render CaseViewPage', async () => { @@ -117,8 +120,12 @@ describe('CaseViewPage', () => { }, }; + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + const props = { ...caseProps, caseData: caseDataWithDamagedRaccoon }; - appMockRenderer = createAppMockRenderer({ features: { metrics: ['alerts.count'] } }); + appMockRenderer = createAppMockRenderer({ features: { metrics: ['alerts.count'] }, license }); const result = appMockRenderer.render(); expect(result.getByTestId('header-page-title')).toHaveTextContent(data.title); diff --git a/x-pack/plugins/cases/public/components/case_view/components/assign_users.tsx b/x-pack/plugins/cases/public/components/case_view/components/assign_users.tsx index 60dc95669a9a0..c1f72f84a6444 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/assign_users.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/assign_users.tsx @@ -170,7 +170,7 @@ const AssignUsersComponent: React.FC = ({ }, [isPopoverOpen, needToUpdateAssignees, onAssigneesChanged, selectedAssignees]); return ( - + { }); let appMockRender: AppMockRenderer; + const platinumLicense = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + + const basicLicense = licensingMock.createLicense({ + license: { type: 'basic' }, + }); + beforeEach(() => { appMockRender = createAppMockRenderer(); }); - it('should render the activity content and main components', () => { + it('should render the activity content and main components', async () => { + appMockRender = createAppMockRenderer({ license: platinumLicense }); const result = appMockRender.render(); + 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-view-status-action-button')).toBeTruthy(); expect(useGetCaseUserActionsMock).toHaveBeenCalledWith(caseData.id, caseData.connector.id); + + await waitForComponentToUpdate(); }); - it('should not render the case view status button when the user does not have update permissions', () => { - appMockRender = createAppMockRenderer({ permissions: noUpdateCasesPermissions() }); + it('should not render the case view status button when the user does not have update permissions', async () => { + appMockRender = createAppMockRenderer({ + permissions: noUpdateCasesPermissions(), + license: platinumLicense, + }); const result = appMockRender.render(); expect(result.getByTestId('case-view-activity')).toBeTruthy(); @@ -130,10 +147,15 @@ describe('Case View Page activity tab', () => { 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); + + await waitForComponentToUpdate(); }); - it('should disable the severity selector when the user does not have update permissions', () => { - appMockRender = createAppMockRenderer({ permissions: noUpdateCasesPermissions() }); + it('should disable the severity selector when the user does not have update permissions', async () => { + appMockRender = createAppMockRenderer({ + permissions: noUpdateCasesPermissions(), + license: platinumLicense, + }); const result = appMockRender.render(); expect(result.getByTestId('case-view-activity')).toBeTruthy(); @@ -142,6 +164,8 @@ describe('Case View Page activity tab', () => { expect(result.getByTestId('connector-edit-header')).toBeTruthy(); expect(result.getByTestId('case-severity-selection')).toBeDisabled(); expect(useGetCaseUserActionsMock).toHaveBeenCalledWith(caseData.id, caseData.connector.id); + + await waitForComponentToUpdate(); }); it('should show a loading when is fetching data is true and hide the user actions activity', () => { @@ -155,4 +179,36 @@ describe('Case View Page activity tab', () => { expect(result.queryByTestId('case-view-activity')).toBeFalsy(); expect(useGetCaseUserActionsMock).toHaveBeenCalledWith(caseData.id, caseData.connector.id); }); + + it('should not render the assignees on basic license', () => { + appMockRender = createAppMockRenderer({ license: basicLicense }); + + const result = appMockRender.render(); + expect(result.queryByTestId('case-view-assignees')).toBeNull(); + }); + + it('should render the assignees on platinum license', async () => { + appMockRender = createAppMockRenderer({ license: platinumLicense }); + + const result = appMockRender.render(); + expect(result.getByTestId('case-view-assignees')).toBeInTheDocument(); + + await waitForComponentToUpdate(); + }); + + it('should not render the connector on basic license', () => { + appMockRender = createAppMockRenderer({ license: basicLicense }); + + const result = appMockRender.render(); + expect(result.queryByTestId('case-view-edit-connector')).toBeNull(); + }); + + it('should render the connector on platinum license', async () => { + appMockRender = createAppMockRenderer({ license: platinumLicense }); + + const result = appMockRender.render(); + + expect(result.getByTestId('case-view-edit-connector')).toBeInTheDocument(); + await waitForComponentToUpdate(); + }); }); diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx index cbf7233a2a894..9af7b6f1cbd17 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx @@ -5,9 +5,12 @@ * 2.0. */ +/* eslint-disable complexity */ + import { EuiFlexGroup, EuiFlexItem, EuiLoadingContent, EuiSpacer } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; import { isEqual, uniq } from 'lodash'; +import { useCasesFeatures } from '../../../common/use_cases_features'; import { useGetCurrentUserProfile } from '../../../containers/user_profiles/use_get_current_user_profile'; import { useBulkGetUserProfiles } from '../../../containers/user_profiles/use_bulk_get_user_profiles'; import { useGetConnectors } from '../../../containers/configure/use_connectors'; @@ -46,6 +49,7 @@ export const CaseViewActivity = ({ }) => { const { permissions } = useCasesContext(); const { getCaseViewUrl } = useCaseViewNavigation(); + const { caseAssignmentAuthorized, pushToServiceAuthorized } = useCasesFeatures(); const { data: userActionsData, isLoading: isLoadingUserActions } = useGetCaseUserActions( caseData.id, @@ -185,14 +189,18 @@ export const CaseViewActivity = ({ )} - - + {caseAssignmentAuthorized ? ( + <> + + + + ) : null} - {userActionsData ? ( + {pushToServiceAuthorized && userActionsData ? ( { const { metricsFeatures } = useCasesFeatures(); diff --git a/x-pack/plugins/cases/public/components/create/form.test.tsx b/x-pack/plugins/cases/public/components/create/form.test.tsx index 169e4029b982b..7459e7b45fed2 100644 --- a/x-pack/plugins/cases/public/components/create/form.test.tsx +++ b/x-pack/plugins/cases/public/components/create/form.test.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { mount } from 'enzyme'; import { act, render } from '@testing-library/react'; +import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; import { NONE_CONNECTOR_ID } from '../../../common/api'; import { useForm, Form, FormHook } from '../../common/shared_imports'; @@ -39,6 +40,7 @@ const initialCaseValue: FormProps = { connectorId: NONE_CONNECTOR_ID, fields: null, syncAlerts: true, + assignees: [], }; const casesFormProps: CreateCaseFormProps = { @@ -48,6 +50,7 @@ const casesFormProps: CreateCaseFormProps = { describe('CreateCaseForm', () => { let globalForm: FormHook; + const MockHookWrapperComponent: React.FC<{ testProviderProps?: unknown }> = ({ children, testProviderProps = {}, @@ -152,4 +155,28 @@ describe('CreateCaseForm', () => { expect(wrapper.find(`[data-test-subj="create-case-loading-spinner"]`).exists()).toBeTruthy(); }); + + it('should not render the assignees on basic license', () => { + const result = render( + + + + ); + + expect(result.queryByTestId('createCaseAssigneesComboBox')).toBeNull(); + }); + + it('should render the assignees on platinum license', () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + + const result = render( + + + + ); + + expect(result.getByTestId('createCaseAssigneesComboBox')).toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/cases/public/components/create/form.tsx b/x-pack/plugins/cases/public/components/create/form.tsx index d34e216c34af9..854261b005e5e 100644 --- a/x-pack/plugins/cases/public/components/create/form.tsx +++ b/x-pack/plugins/cases/public/components/create/form.tsx @@ -30,7 +30,7 @@ import { InsertTimeline } from '../insert_timeline'; import { UseCreateAttachments } from '../../containers/use_create_attachments'; import { SubmitCaseButton } from './submit_button'; import { FormContext } from './form_context'; -import { useCasesFeatures } from '../cases_context/use_cases_features'; +import { useCasesFeatures } from '../../common/use_cases_features'; import { CreateCaseOwnerSelector } from './owner_selector'; import { useCasesContext } from '../cases_context/use_cases_context'; import { useAvailableCasesOwners } from '../app/use_available_owners'; @@ -75,7 +75,7 @@ const empty: ActionConnector[] = []; export const CreateCaseFormFields: React.FC = React.memo( ({ connectors, isLoadingConnectors, withSteps }) => { const { isSubmitting } = useFormContext(); - const { isSyncAlertsEnabled } = useCasesFeatures(); + const { isSyncAlertsEnabled, caseAssignmentAuthorized } = useCasesFeatures(); const { owner } = useCasesContext(); const availableOwners = useAvailableCasesOwners(); @@ -87,9 +87,11 @@ export const CreateCaseFormFields: React.FC = React.m children: ( <> - <Container> - <Assignees isLoading={isSubmitting} /> - </Container> + {caseAssignmentAuthorized ? ( + <Container> + <Assignees isLoading={isSubmitting} /> + </Container> + ) : null} <Container> <Tags isLoading={isSubmitting} /> </Container> @@ -111,7 +113,7 @@ export const CreateCaseFormFields: React.FC<CreateCaseFormFieldsProps> = React.m </> ), }), - [isSubmitting, canShowCaseSolutionSelection, availableOwners] + [isSubmitting, caseAssignmentAuthorized, canShowCaseSolutionSelection, availableOwners] ); const secondStep = useMemo( diff --git a/x-pack/plugins/cases/public/components/create/form_context.test.tsx b/x-pack/plugins/cases/public/components/create/form_context.test.tsx index 8b45258889e5c..26b14510b481b 100644 --- a/x-pack/plugins/cases/public/components/create/form_context.test.tsx +++ b/x-pack/plugins/cases/public/components/create/form_context.test.tsx @@ -43,6 +43,7 @@ import { useGetConnectors } from '../../containers/configure/use_connectors'; import { useGetTags } from '../../containers/use_get_tags'; import { waitForComponentToUpdate } from '../../common/test_utils'; import { userProfiles } from '../../containers/user_profiles/api.mock'; +import { useLicense } from '../../common/use_license'; const sampleId = 'case-id'; @@ -61,6 +62,7 @@ jest.mock('../connectors/jira/use_get_issues'); jest.mock('../connectors/servicenow/use_get_choices'); jest.mock('../../common/lib/kibana'); jest.mock('../../containers/user_profiles/api'); +jest.mock('../../common/use_license'); const useGetConnectorsMock = useGetConnectors as jest.Mock; const useCaseConfigureMock = useCaseConfigure as jest.Mock; @@ -75,6 +77,7 @@ const useGetChoicesMock = useGetChoices as jest.Mock; const postCase = jest.fn(); const pushCaseToExternalService = jest.fn(); const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>; +const useLicenseMock = useLicense as jest.Mock; const defaultPostCase = { isLoading: false, @@ -148,10 +151,13 @@ describe('Create case', () => { data: sampleTags, refetch, })); + useKibanaMock().services.triggersActionsUi.actionTypeRegistry.get = jest.fn().mockReturnValue({ actionTypeTitle: '.servicenow', iconClass: 'logoSecurity', }); + + useLicenseMock.mockReturnValue({ isAtLeastPlatinum: () => true }); }); beforeEach(() => { @@ -1009,5 +1015,18 @@ describe('Create case', () => { assignees: [{ uid: userProfiles[0].uid }], }); }); + + it('should not render the assignees on basic license', async () => { + useLicenseMock.mockReturnValue({ isAtLeastPlatinum: () => false }); + + const renderResult = mockedContext.render( + <FormContext onSuccess={onFormSubmitSuccess}> + <CreateCaseFormFields {...defaultCreateCaseForm} /> + <SubmitCaseButton /> + </FormContext> + ); + + expect(renderResult.queryByTestId('createCaseAssigneesComboBox')).toBeNull(); + }); }); }); diff --git a/x-pack/plugins/cases/public/components/create/form_context.tsx b/x-pack/plugins/cases/public/components/create/form_context.tsx index c037d22012158..01dbb7fbf40d0 100644 --- a/x-pack/plugins/cases/public/components/create/form_context.tsx +++ b/x-pack/plugins/cases/public/components/create/form_context.tsx @@ -19,7 +19,7 @@ import { useCreateAttachments, } from '../../containers/use_create_attachments'; import { useCasesContext } from '../cases_context/use_cases_context'; -import { useCasesFeatures } from '../cases_context/use_cases_features'; +import { useCasesFeatures } from '../../common/use_cases_features'; import { getConnectorById } from '../utils'; import { CaseAttachmentsWithoutOwner } from '../../types'; import { useGetConnectors } from '../../containers/configure/use_connectors'; diff --git a/x-pack/plugins/cases/public/components/create/index.test.tsx b/x-pack/plugins/cases/public/components/create/index.test.tsx index ee2d2d9a4468c..4a2449397bbcc 100644 --- a/x-pack/plugins/cases/public/components/create/index.test.tsx +++ b/x-pack/plugins/cases/public/components/create/index.test.tsx @@ -64,7 +64,7 @@ const fillForm = (wrapper: ReactWrapper) => { act(() => { ( - wrapper.find(EuiComboBox).at(1).props() as unknown as { + wrapper.find(EuiComboBox).at(0).props() as unknown as { onChange: (a: EuiComboBoxOptionOption[]) => void; } ).onChange(sampleTags.map((tag) => ({ label: tag }))); diff --git a/x-pack/plugins/cases/public/components/create/title.tsx b/x-pack/plugins/cases/public/components/create/title.tsx index 9918818a6e75f..ae8f517173132 100644 --- a/x-pack/plugins/cases/public/components/create/title.tsx +++ b/x-pack/plugins/cases/public/components/create/title.tsx @@ -24,8 +24,6 @@ const TitleComponent: React.FC<Props> = ({ isLoading }) => ( autoFocus: true, fullWidth: true, disabled: isLoading, - // TODO set a custom test subj - // 'data-test-subj': 'caseTitleInput', }, }} /> diff --git a/x-pack/plugins/cases/public/components/edit_connector/index.tsx b/x-pack/plugins/cases/public/components/edit_connector/index.tsx index cbfea7913dec7..fe3e980016e4c 100644 --- a/x-pack/plugins/cases/public/components/edit_connector/index.tsx +++ b/x-pack/plugins/cases/public/components/edit_connector/index.tsx @@ -283,6 +283,7 @@ export const EditConnector = React.memo( gutterSize="xs" justifyContent="spaceBetween" responsive={false} + data-test-subj="case-view-edit-connector" > <EuiFlexItem grow={false} data-test-subj="connector-edit-header"> <h4>{i18n.CONNECTORS}</h4> diff --git a/x-pack/plugins/cases/public/components/user_actions/index.tsx b/x-pack/plugins/cases/public/components/user_actions/index.tsx index ea7c5c1e08b16..6d96d9613a754 100644 --- a/x-pack/plugins/cases/public/components/user_actions/index.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/index.tsx @@ -67,7 +67,7 @@ const MyEuiCommentList = styled(EuiCommentList)` flex-grow: 1; } - & .comment-action.empty-comment [class*="euiCommentEvent-regular] { + & .comment-action.empty-comment [class*="euiCommentEvent-regular"] { box-shadow: none; .euiCommentEvent__header { padding: ${theme.eui.euiSizeM} ${theme.eui.euiSizeS}; diff --git a/x-pack/plugins/cases/public/types.ts b/x-pack/plugins/cases/public/types.ts index c7ebe23759949..e7ad03b482cd6 100644 --- a/x-pack/plugins/cases/public/types.ts +++ b/x-pack/plugins/cases/public/types.ts @@ -20,6 +20,7 @@ import type { SpacesPluginStart } from '@kbn/spaces-plugin/public'; import type { TriggersAndActionsUIPublicPluginStart as TriggersActionsStart } from '@kbn/triggers-actions-ui-plugin/public'; import type { DistributiveOmit } from '@elastic/eui'; import type { ApmBase } from '@elastic/apm-rum'; +import { LicensingPluginStart } from '@kbn/licensing-plugin/public'; import type { CasesByAlertId, CasesByAlertIDRequest, @@ -55,6 +56,7 @@ export interface CasesPluginSetup { export interface CasesPluginStart { data: DataPublicPluginStart; embeddable: EmbeddableStart; + licensing?: LicensingPluginStart; lens: LensPublicStart; storage: Storage; triggersActionsUi: TriggersActionsStart; diff --git a/x-pack/plugins/cases/server/client/cases/create.ts b/x-pack/plugins/cases/server/client/cases/create.ts index dad55f528569a..917a88905bf69 100644 --- a/x-pack/plugins/cases/server/client/cases/create.ts +++ b/x-pack/plugins/cases/server/client/cases/create.ts @@ -41,7 +41,7 @@ export const create = async ( ): Promise<CaseResponse> => { const { unsecuredSavedObjectsClient, - services: { caseService, userActionService }, + services: { caseService, userActionService, licensingService }, user, logger, authorization: auth, @@ -72,6 +72,20 @@ export const create = async ( entities: [{ owner: query.owner, id: savedObjectID }], }); + /** + * Assign users to a case is only available to Platinum+ + */ + + if (query.assignees && query.assignees.length !== 0) { + const hasPlatinumLicenseOrGreater = await licensingService.isAtLeastPlatinum(); + + if (!hasPlatinumLicenseOrGreater) { + throw Boom.forbidden( + 'In order to assign users to cases, you must be subscribed to an Elastic Platinum license' + ); + } + } + const newCase = await caseService.postNewCase({ attributes: transformNewCase({ user, diff --git a/x-pack/plugins/cases/server/client/cases/find.ts b/x-pack/plugins/cases/server/client/cases/find.ts index eeb1cf6757268..33aa9ebaacdcd 100644 --- a/x-pack/plugins/cases/server/client/cases/find.ts +++ b/x-pack/plugins/cases/server/client/cases/find.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { isEmpty } from 'lodash'; import Boom from '@hapi/boom'; import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; @@ -37,7 +38,7 @@ export const find = async ( clientArgs: CasesClientArgs ): Promise<CasesFindResponse> => { const { - services: { caseService }, + services: { caseService, licensingService }, authorization, logger, } = clientArgs; @@ -53,6 +54,20 @@ export const find = async ( const { filter: authorizationFilter, ensureSavedObjectsAreAuthorized } = await authorization.getAuthorizationFilter(Operations.findCases); + /** + * Assign users to a case is only available to Platinum+ + */ + + if (!isEmpty(queryParams.assignees)) { + const hasPlatinumLicenseOrGreater = await licensingService.isAtLeastPlatinum(); + + if (!hasPlatinumLicenseOrGreater) { + throw Boom.forbidden( + 'In order to filter cases by assignees, you must be subscribed to an Elastic Platinum license' + ); + } + } + const queryArgs: ConstructQueryParams = { tags: queryParams.tags, reporters: queryParams.reporters, diff --git a/x-pack/plugins/cases/server/client/cases/update.ts b/x-pack/plugins/cases/server/client/cases/update.ts index 476df60fc81a9..b10fc9ddc9761 100644 --- a/x-pack/plugins/cases/server/client/cases/update.ts +++ b/x-pack/plugins/cases/server/client/cases/update.ts @@ -78,6 +78,32 @@ function throwIfTitleIsInvalid(requests: UpdateRequestWithOriginalCase[]) { } } +/** + * Throws an error if any of the requests attempt to update the assignees of the case + * without the appropriate license + */ +function throwIfUpdateAssigneesWithoutValidLicense( + requests: UpdateRequestWithOriginalCase[], + hasPlatinumLicenseOrGreater: boolean +) { + if (hasPlatinumLicenseOrGreater) { + return; + } + + const requestsUpdatingAssignees = requests.filter( + ({ updateReq }) => updateReq.assignees !== undefined + ); + + if (requestsUpdatingAssignees.length > 0) { + const ids = requestsUpdatingAssignees.map(({ updateReq }) => updateReq.id); + throw Boom.forbidden( + `In order to assign users to cases, you must be subscribed to an Elastic Platinum license, ids: [${ids.join( + ', ' + )}]` + ); + } +} + /** * Get the id from a reference in a comment for a specific type. */ @@ -230,7 +256,7 @@ export const update = async ( ): Promise<CasesResponse> => { const { unsecuredSavedObjectsClient, - services: { caseService, userActionService, alertsService }, + services: { caseService, userActionService, alertsService, licensingService }, user, logger, authorization, @@ -301,8 +327,11 @@ export const update = async ( throw Boom.notAcceptable('All update fields are identical to current version.'); } + const hasPlatinumLicense = await licensingService.isAtLeastPlatinum(); + throwIfUpdateOwner(updateCases); throwIfTitleIsInvalid(updateCases); + throwIfUpdateAssigneesWithoutValidLicense(updateCases, hasPlatinumLicense); const updatedCases = await patchCases({ caseService, user, casesToUpdate: updateCases }); diff --git a/x-pack/plugins/cases/server/client/factory.ts b/x-pack/plugins/cases/server/client/factory.ts index 42f5b0f4dd9c7..5f4fc099db44d 100644 --- a/x-pack/plugins/cases/server/client/factory.ts +++ b/x-pack/plugins/cases/server/client/factory.ts @@ -17,6 +17,7 @@ import { PluginStartContract as FeaturesPluginStart } from '@kbn/features-plugin import { PluginStartContract as ActionsPluginStart } from '@kbn/actions-plugin/server'; import { LensServerPluginSetup } from '@kbn/lens-plugin/server'; import { SpacesPluginStart } from '@kbn/spaces-plugin/server'; +import { LicensingPluginStart } from '@kbn/licensing-plugin/server'; import { SAVED_OBJECT_TYPES } from '../../common/constants'; import { Authorization } from '../authorization/authorization'; import { @@ -33,6 +34,7 @@ import { CasesClient, createCasesClient } from '.'; import { PersistableStateAttachmentTypeRegistry } from '../attachment_framework/persistable_state_registry'; import { ExternalReferenceAttachmentTypeRegistry } from '../attachment_framework/external_reference_registry'; import { CasesServices } from './types'; +import { LicensingService } from '../services/licensing'; interface CasesClientFactoryArgs { securityPluginSetup?: SecurityPluginSetup; @@ -40,6 +42,7 @@ interface CasesClientFactoryArgs { spacesPluginStart: SpacesPluginStart; featuresPluginStart: FeaturesPluginStart; actionsPluginStart: ActionsPluginStart; + licensingPluginStart: LicensingPluginStart; lensEmbeddableFactory: LensServerPluginSetup['lensEmbeddableFactory']; persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry; externalReferenceAttachmentTypeRegistry: ExternalReferenceAttachmentTypeRegistry; @@ -148,6 +151,8 @@ export class CasesClientFactory { attachmentService, }); + const licensingService = new LicensingService(this.options.licensingPluginStart.license$); + return { alertsService: new AlertService(esClient, this.logger), caseService, @@ -158,6 +163,7 @@ export class CasesClientFactory { this.options.persistableStateAttachmentTypeRegistry ), attachmentService, + licensingService, }; } } diff --git a/x-pack/plugins/cases/server/client/types.ts b/x-pack/plugins/cases/server/client/types.ts index 8ffd3aa5299f9..b0e23921bbd0b 100644 --- a/x-pack/plugins/cases/server/client/types.ts +++ b/x-pack/plugins/cases/server/client/types.ts @@ -22,6 +22,7 @@ import { } from '../services'; import { PersistableStateAttachmentTypeRegistry } from '../attachment_framework/persistable_state_registry'; import { ExternalReferenceAttachmentTypeRegistry } from '../attachment_framework/external_reference_registry'; +import { LicensingService } from '../services/licensing'; export interface CasesServices { alertsService: AlertService; @@ -30,6 +31,7 @@ export interface CasesServices { connectorMappingsService: ConnectorMappingsService; userActionService: CaseUserActionService; attachmentService: AttachmentService; + licensingService: LicensingService; } /** diff --git a/x-pack/plugins/cases/server/plugin.ts b/x-pack/plugins/cases/server/plugin.ts index a0acd302a9373..a333a84827212 100644 --- a/x-pack/plugins/cases/server/plugin.ts +++ b/x-pack/plugins/cases/server/plugin.ts @@ -30,6 +30,7 @@ import { TaskManagerStartContract, } from '@kbn/task-manager-plugin/server'; import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; +import { LicensingPluginStart } from '@kbn/licensing-plugin/server'; import { APP_ID } from '../common/constants'; import { @@ -65,6 +66,7 @@ export interface PluginsSetup { export interface PluginsStart { actions: ActionsPluginStart; features: FeaturesPluginStart; + licensing: LicensingPluginStart; taskManager?: TaskManagerStartContract; security?: SecurityPluginStart; spaces: SpacesPluginStart; @@ -178,6 +180,7 @@ export class CasePlugin { spacesPluginStart: plugins.spaces, featuresPluginStart: plugins.features, actionsPluginStart: plugins.actions, + licensingPluginStart: plugins.licensing, /** * Lens will be always defined as * it is declared as required plugin in kibana.json diff --git a/x-pack/plugins/cases/server/services/licensing.ts b/x-pack/plugins/cases/server/services/licensing.ts new file mode 100644 index 0000000000000..46a59131056f3 --- /dev/null +++ b/x-pack/plugins/cases/server/services/licensing.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { firstValueFrom, Observable } from 'rxjs'; +import { ILicense, LicenseType } from '@kbn/licensing-plugin/server'; + +export class LicensingService { + private readonly license$: Observable<ILicense>; + + constructor(license$: Observable<ILicense>) { + this.license$ = license$; + } + + public async getLicenseInformation(): Promise<ILicense> { + return firstValueFrom(this.license$); + } + + public async isAtLeast(level: LicenseType): Promise<boolean> { + const license = await this.getLicenseInformation(); + return !!license && license.isAvailable && license.isActive && license.hasAtLeast(level); + } + + public async isAtLeastPlatinum() { + return this.isAtLeast('platinum'); + } + + public async isAtLeastGold() { + return this.isAtLeast('gold'); + } + + public async isAtLeastEnterprise() { + return this.isAtLeast('enterprise'); + } +} diff --git a/x-pack/plugins/ml/public/application/app.tsx b/x-pack/plugins/ml/public/application/app.tsx index fbfea9ef9a05b..8d5958c2f5574 100644 --- a/x-pack/plugins/ml/public/application/app.tsx +++ b/x-pack/plugins/ml/public/application/app.tsx @@ -27,7 +27,7 @@ import { ML_APP_LOCATOR, ML_PAGES } from '../../common/constants/locator'; export type MlDependencies = Omit< MlSetupDependencies, - 'share' | 'fieldFormats' | 'maps' | 'cases' + 'share' | 'fieldFormats' | 'maps' | 'cases' | 'licensing' > & MlStartDependencies; @@ -91,6 +91,7 @@ const App: FC<AppProps> = ({ coreStart, deps, appMountParams }) => { charts: deps.charts, cases: deps.cases, unifiedSearch: deps.unifiedSearch, + licensing: deps.licensing, ...coreStart, }; diff --git a/x-pack/plugins/ml/public/application/license/check_license.tsx b/x-pack/plugins/ml/public/application/license/check_license.tsx index 9436614effe3c..ddf59ac1d7e5d 100644 --- a/x-pack/plugins/ml/public/application/license/check_license.tsx +++ b/x-pack/plugins/ml/public/application/license/check_license.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import type { LicensingPluginSetup } from '@kbn/licensing-plugin/public'; +import type { LicensingPluginStart } from '@kbn/licensing-plugin/public'; import type { CoreStart } from '@kbn/core/public'; import { MlLicense } from '../../../common/license'; import { MlClientLicense } from './ml_client_license'; @@ -16,18 +16,18 @@ let mlLicense: MlClientLicense | null = null; * Create a new mlLicense and cache it for later checks * * @export - * @param {LicensingPluginSetup} licensingSetup + * @param {LicensingPluginStart} licensingStart * @param application * @param postInitFunctions * @returns {MlClientLicense} */ export function setLicenseCache( - licensingSetup: LicensingPluginSetup, + licensingStart: LicensingPluginStart, application: CoreStart['application'], postInitFunctions?: Array<(lic: MlLicense) => void> ) { mlLicense = new MlClientLicense(application); - mlLicense.setup(licensingSetup.license$, postInitFunctions); + mlLicense.setup(licensingStart.license$, postInitFunctions); return mlLicense; } diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index 3037d84180349..2e69266e63392 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -29,7 +29,7 @@ import { AppStatus, AppUpdater, DEFAULT_APP_CATEGORIES } from '@kbn/core/public' import type { UiActionsSetup, UiActionsStart } from '@kbn/ui-actions-plugin/public'; import type { LicenseManagementUIPluginSetup } from '@kbn/license-management-plugin/public'; -import type { LicensingPluginSetup } from '@kbn/licensing-plugin/public'; +import type { LicensingPluginSetup, LicensingPluginStart } from '@kbn/licensing-plugin/public'; import type { SecurityPluginStart } from '@kbn/security-plugin/public'; import type { MapsStartApi, MapsSetupApi } from '@kbn/maps-plugin/public'; @@ -54,6 +54,7 @@ import { PLUGIN_ICON_SOLUTION, PLUGIN_ID } from '../common/constants/app'; export interface MlStartDependencies { data: DataPublicPluginStart; unifiedSearch: UnifiedSearchPublicPluginStart; + licensing: LicensingPluginStart; share: SharePluginStart; uiActions: UiActionsStart; spaces?: SpacesPluginStart; @@ -120,7 +121,7 @@ export class MlPlugin implements Plugin<MlPluginSetup, MlPluginStart> { dashboard: pluginsStart.dashboard, share: pluginsStart.share, security: pluginsStart.security, - licensing: pluginsSetup.licensing, + licensing: pluginsStart.licensing, management: pluginsSetup.management, licenseManagement: pluginsSetup.licenseManagement, home: pluginsSetup.home, diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/basic/cases/assignees.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/basic/cases/assignees.ts new file mode 100644 index 0000000000000..12be90df3afa5 --- /dev/null +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/basic/cases/assignees.ts @@ -0,0 +1,72 @@ +/* + * 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 { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { getPostCaseRequest } from '../../../../common/lib/mock'; +import { + createCase, + updateCase, + findCases, + deleteAllCaseItems, +} from '../../../../common/lib/utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('assignees', () => { + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + describe('create_case', () => { + it('should get 403 when trying to create a a case with assignees', async () => { + await createCase( + supertest, + { + ...getPostCaseRequest(), + assignees: [{ uid: '123' }], + }, + 403 + ); + }); + }); + + describe('find_case', () => { + it('should get 403 when trying to filter cases by assignees', async () => { + await createCase(supertest, { + ...getPostCaseRequest(), + }); + + await findCases({ supertest, query: { assignees: '123' }, expectedHttpCode: 403 }); + }); + }); + + describe('patch_case', () => { + it('should get 403 when trying to update assignees on a case', async () => { + const postedCase = await createCase(supertest, { + ...getPostCaseRequest(), + }); + + await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + assignees: [{ uid: '123' }], + }, + ], + }, + expectedHttpCode: 403, + }); + }); + }); + }); +}; diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/basic/index.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/basic/index.ts index b2229e8e98318..ee68a9dbdbf52 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/basic/index.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/basic/index.ts @@ -27,6 +27,7 @@ export default ({ loadTestFile, getService }: FtrProviderContext): void => { }); // Basic + loadTestFile(require.resolve('./cases/assignees')); loadTestFile(require.resolve('./cases/push_case')); loadTestFile(require.resolve('./configure/get_connectors')); diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/index.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/index.ts index 64476e1db2d2b..5de45ed0869c9 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/index.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/index.ts @@ -25,7 +25,6 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./cases/get_case')); loadTestFile(require.resolve('./cases/patch_cases')); loadTestFile(require.resolve('./cases/post_case')); - loadTestFile(require.resolve('./cases/assignees')); loadTestFile(require.resolve('./cases/resolve_case')); loadTestFile(require.resolve('./cases/reporters/get_reporters')); loadTestFile(require.resolve('./cases/status/get_status')); diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/user_actions/get_all_user_actions.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/user_actions/get_all_user_actions.ts index 4167ceb3d70be..aacb5f6c8ae17 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/user_actions/get_all_user_actions.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/user_actions/get_all_user_actions.ts @@ -197,37 +197,6 @@ export default ({ getService }: FtrProviderContext): void => { expect(deleteTagsUserAction.payload).to.eql({ tags: ['defacement'] }); }); - it('creates an add and delete assignees user action', async () => { - const theCase = await createCase( - supertest, - getPostCaseRequest({ assignees: [{ uid: '1' }] }) - ); - await updateCase({ - supertest, - params: { - cases: [ - { - id: theCase.id, - version: theCase.version, - assignees: [{ uid: '2' }, { uid: '3' }], - }, - ], - }, - }); - - const userActions = await getCaseUserActions({ supertest, caseID: theCase.id }); - const addAssigneesUserAction = userActions[1]; - const deleteAssigneesUserAction = userActions[2]; - - expect(userActions.length).to.eql(3); - expect(addAssigneesUserAction.type).to.eql('assignees'); - expect(addAssigneesUserAction.action).to.eql('add'); - expect(addAssigneesUserAction.payload).to.eql({ assignees: [{ uid: '2' }, { uid: '3' }] }); - expect(deleteAssigneesUserAction.type).to.eql('assignees'); - expect(deleteAssigneesUserAction.action).to.eql('delete'); - expect(deleteAssigneesUserAction.payload).to.eql({ assignees: [{ uid: '1' }] }); - }); - it('creates an update title user action', async () => { const newTitle = 'Such a great title'; const theCase = await createCase(supertest, postCaseReq); diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/assignees.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/cases/assignees.ts similarity index 100% rename from x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/assignees.ts rename to x-pack/test/cases_api_integration/security_and_spaces/tests/trial/cases/assignees.ts diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/cases/user_actions/get_all_user_actions.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/cases/user_actions/get_all_user_actions.ts index ee93dd1b07aab..8f059bfa89de6 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/cases/user_actions/get_all_user_actions.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/cases/user_actions/get_all_user_actions.ts @@ -10,8 +10,9 @@ import expect from '@kbn/expect'; import { UserActionWithResponse, PushedUserAction } from '@kbn/cases-plugin/common/api'; import { FtrProviderContext } from '../../../../../../common/ftr_provider_context'; -import { defaultUser } from '../../../../../common/lib/mock'; +import { defaultUser, getPostCaseRequest } from '../../../../../common/lib/mock'; import { + createCase, createCaseWithConnector, deleteCasesByESQuery, deleteCasesUserActions, @@ -20,6 +21,7 @@ import { getCaseUserActions, getServiceNowSimulationServer, pushCase, + updateCase, updateConfiguration, } from '../../../../../common/lib/utils'; @@ -113,5 +115,36 @@ export default ({ getService }: FtrProviderContext): void => { expect(statusUserAction.action).to.eql('update'); expect(statusUserAction.payload).to.eql({ status: 'closed' }); }); + + it('creates an add and delete assignees user action', async () => { + const theCase = await createCase( + supertest, + getPostCaseRequest({ assignees: [{ uid: '1' }] }) + ); + await updateCase({ + supertest, + params: { + cases: [ + { + id: theCase.id, + version: theCase.version, + assignees: [{ uid: '2' }, { uid: '3' }], + }, + ], + }, + }); + + const userActions = await getCaseUserActions({ supertest, caseID: theCase.id }); + const addAssigneesUserAction = userActions[1]; + const deleteAssigneesUserAction = userActions[2]; + + expect(userActions.length).to.eql(3); + expect(addAssigneesUserAction.type).to.eql('assignees'); + expect(addAssigneesUserAction.action).to.eql('add'); + expect(addAssigneesUserAction.payload).to.eql({ assignees: [{ uid: '2' }, { uid: '3' }] }); + expect(deleteAssigneesUserAction.type).to.eql('assignees'); + expect(deleteAssigneesUserAction.action).to.eql('delete'); + expect(deleteAssigneesUserAction.payload).to.eql({ assignees: [{ uid: '1' }] }); + }); }); }; diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/index.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/index.ts index 5fbcf00e7364e..8fd38dcc0d32d 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/index.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/index.ts @@ -29,6 +29,7 @@ export default ({ loadTestFile, getService }: FtrProviderContext): void => { // Trial loadTestFile(require.resolve('./cases/push_case')); loadTestFile(require.resolve('./cases/user_actions/get_all_user_actions')); + loadTestFile(require.resolve('./cases/assignees')); loadTestFile(require.resolve('./configure')); // sub privileges are only available with a license above basic loadTestFile(require.resolve('./delete_sub_privilege')); diff --git a/x-pack/test/functional_with_es_ssl/fixtures/plugins/cases/kibana.json b/x-pack/test/functional_with_es_ssl/fixtures/plugins/cases/kibana.json index c7918341da86e..b1ca4355e4e61 100644 --- a/x-pack/test/functional_with_es_ssl/fixtures/plugins/cases/kibana.json +++ b/x-pack/test/functional_with_es_ssl/fixtures/plugins/cases/kibana.json @@ -3,7 +3,7 @@ "owner": { "name": "Response Ops", "githubTeam": "response-ops" }, "version": "1.0.0", "kibanaVersion": "kibana", - "requiredPlugins": ["cases", "embeddable", "lens", "kibanaReact", "esUiShared", "security"], + "requiredPlugins": ["cases", "embeddable", "lens", "kibanaReact", "esUiShared", "security", "licensing"], "server": true, "ui": true }