From 886289d206e52a1ca7d9c3a04a72313565cbd03a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=A1nchez?= Date: Tue, 20 Dec 2022 15:00:14 +0100 Subject: [PATCH] [Security Soution][Endpoint] Uses RBAC in policy details page for artifacts tabs (#147676) ## Summary - Hide artifact tabs when no read permissions. - Hide manage/add artifacts button actions when no write permissions. - Redirects user to policy details page when missing privileges and add a toast with error message. - Remove superuser check for `canCreateArtifactsByPolicy` privielge - Also updates and adds unit tests --- .../common/endpoint/service/authz/authz.ts | 2 +- .../policy_artifacts_empty_unassigned.tsx | 35 ++- .../policy_artifacts_empty_unexisting.tsx | 26 ++- .../view/artifacts/empty/translations.ts | 8 + .../layout/policy_artifacts_layout.test.tsx | 80 +++++-- .../layout/policy_artifacts_layout.tsx | 14 +- .../list/policy_artifacts_list.test.tsx | 6 +- .../artifacts/list/policy_artifacts_list.tsx | 8 +- .../policy/view/artifacts/translations.ts | 1 + .../pages/policy/view/policy_details.test.tsx | 96 ++++---- .../view/tabs/blocklists_translations.ts | 8 + .../view/tabs/event_filters_translations.ts | 8 + .../host_isolation_exceptions_translations.ts | 9 + .../pages/policy/view/tabs/policy_tabs.tsx | 220 ++++++++++-------- .../view/tabs/trusted_apps_translations.ts | 8 + 15 files changed, 337 insertions(+), 192 deletions(-) diff --git a/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.ts b/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.ts index 5c83571b6373e..87c56ff4d94b1 100644 --- a/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.ts +++ b/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.ts @@ -218,7 +218,7 @@ export const calculateEndpointAuthz = ( canReadSecuritySolution, canAccessFleet: fleetAuthz?.fleet.all ?? userRoles.includes('superuser'), canAccessEndpointManagement: hasEndpointManagementAccess, - canCreateArtifactsByPolicy: hasEndpointManagementAccess && isPlatinumPlusLicense, + canCreateArtifactsByPolicy: isPlatinumPlusLicense, canWriteEndpointList, canReadEndpointList, canWritePolicyManagement, diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/policy_artifacts_empty_unassigned.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/policy_artifacts_empty_unassigned.tsx index 7b50e68b85fa1..98b9d3bd8404d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/policy_artifacts_empty_unassigned.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/policy_artifacts_empty_unassigned.tsx @@ -22,12 +22,21 @@ interface CommonProps { policyName: string; listId: string; labels: typeof POLICY_ARTIFACT_EMPTY_UNASSIGNED_LABELS; + canWriteArtifact?: boolean; getPolicyArtifactsPath: (policyId: string) => string; getArtifactPath: (location?: Partial) => string; } export const PolicyArtifactsEmptyUnassigned = memo( - ({ policyId, policyName, listId, labels, getPolicyArtifactsPath, getArtifactPath }) => { + ({ + policyId, + policyName, + listId, + labels, + canWriteArtifact = false, + getPolicyArtifactsPath, + getArtifactPath, + }) => { const { canCreateArtifactsByPolicy } = useUserPrivileges().endpointPrivileges; const { onClickHandler, toRouteUrl } = useGetLinkTo( policyId, @@ -50,24 +59,34 @@ export const PolicyArtifactsEmptyUnassigned = memo( iconType="plusInCircle" data-test-subj="policy-artifacts-empty-unassigned" title={

{labels.emptyUnassignedTitle}

} - body={labels.emptyUnassignedMessage(policyName)} + body={ + canWriteArtifact + ? labels.emptyUnassignedMessage(policyName) + : labels.emptyUnassignedNoPrivilegesMessage(policyName) + } actions={[ - ...(canCreateArtifactsByPolicy + ...(canCreateArtifactsByPolicy && canWriteArtifact ? [ {labels.emptyUnassignedPrimaryActionButtonTitle} , ] : []), - // eslint-disable-next-line @elastic/eui/href-or-on-click - - {labels.emptyUnassignedSecondaryActionButtonTitle} - , + canWriteArtifact ? ( + // eslint-disable-next-line @elastic/eui/href-or-on-click + + {labels.emptyUnassignedSecondaryActionButtonTitle} + + ) : null, ]} /> diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/policy_artifacts_empty_unexisting.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/policy_artifacts_empty_unexisting.tsx index 9a2068a58af5d..1b1e52c872465 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/policy_artifacts_empty_unexisting.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/policy_artifacts_empty_unexisting.tsx @@ -19,12 +19,20 @@ interface CommonProps { policyId: string; policyName: string; labels: typeof POLICY_ARTIFACT_EMPTY_UNEXISTING_LABELS; + canWriteArtifact?: boolean; getPolicyArtifactsPath: (policyId: string) => string; getArtifactPath: (location?: Partial) => string; } export const PolicyArtifactsEmptyUnexisting = memo( - ({ policyId, policyName, labels, getPolicyArtifactsPath, getArtifactPath }) => { + ({ + policyId, + policyName, + labels, + canWriteArtifact = false, + getPolicyArtifactsPath, + getArtifactPath, + }) => { const { onClickHandler, toRouteUrl } = useGetLinkTo( policyId, policyName, @@ -42,10 +50,18 @@ export const PolicyArtifactsEmptyUnexisting = memo( title={

{labels.emptyUnexistingTitle}

} body={labels.emptyUnexistingMessage} actions={ - // eslint-disable-next-line @elastic/eui/href-or-on-click - - {labels.emptyUnexistingPrimaryActionButtonTitle} - + canWriteArtifact ? ( + // eslint-disable-next-line @elastic/eui/href-or-on-click + + {labels.emptyUnexistingPrimaryActionButtonTitle} + + ) : null } /> diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/translations.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/translations.ts index 8fdb3f2dbfb5b..0b58b724bd837 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/translations.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/empty/translations.ts @@ -30,6 +30,14 @@ export const POLICY_ARTIFACT_EMPTY_UNASSIGNED_LABELS = Object.freeze({ defaultMessage: 'Manage artifacts', } ), + emptyUnassignedNoPrivilegesMessage: (policyName: string): string => + i18n.translate( + 'xpack.securitySolution.endpoint.policy.artifacts.empty.unassigned.noPrivileges.content', + { + defaultMessage: 'There are currently no artifacts assigned to {policyName}.', + values: { policyName }, + } + ), }); export const POLICY_ARTIFACT_EMPTY_UNEXISTING_LABELS = Object.freeze({ diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.test.tsx index 958e2aa5f75ed..08e47ebcb0431 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.test.tsx @@ -20,19 +20,26 @@ import type { ImmutableObject, PolicyData } from '../../../../../../../common/en import { parsePoliciesAndFilterToKql } from '../../../../../common/utils'; import { eventFiltersListQueryHttpMock } from '../../../../event_filters/test_utils'; import { getFoundExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/found_exception_list_item_schema.mock'; -import { getEndpointPrivilegesInitialStateMock } from '../../../../../../common/components/user_privileges/endpoint/mocks'; import { POLICY_ARTIFACT_EVENT_FILTERS_LABELS } from '../../tabs/event_filters_translations'; import { EventFiltersApiClient } from '../../../../event_filters/service/api_client'; import { SEARCHABLE_FIELDS as EVENT_FILTERS_SEARCHABLE_FIELDS } from '../../../../event_filters/constants'; import { FormattedMessage } from '@kbn/i18n-react'; +import { useUserPrivileges } from '../../../../../../common/components/user_privileges'; -let render: (externalPrivileges?: boolean) => Promise>; +jest.mock('../../../../../../common/components/user_privileges'); + +interface MockedAPIArgs { + query: { filter: string }; +} + +let render: (canWriteArtifact?: boolean) => Promise>; let mockedContext: AppContextTestRender; let renderResult: ReturnType; let policyItem: ImmutableObject; const generator = new EndpointDocGenerator(); let mockedApi: ReturnType; let history: AppContextTestRender['history']; +const useUserPrivilegesMock = useUserPrivileges as jest.Mock; const getEventFiltersLabels = () => ({ ...POLICY_ARTIFACT_EVENT_FILTERS_LABELS, @@ -46,6 +53,9 @@ const getEventFiltersLabels = () => ({ }); describe('Policy artifacts layout', () => { + const isFilteredByPolicyQuery = (args?: { query: { filter: string } }) => + args && args.query.filter === parsePoliciesAndFilterToKql({ policies: [policyItem.id, 'all'] }); + beforeEach(() => { mockedContext = createAppRootMockRenderer(); mockedApi = eventFiltersListQueryHttpMock(mockedContext.coreStart.http); @@ -53,10 +63,12 @@ describe('Policy artifacts layout', () => { policyItem = generator.generatePolicyPackagePolicy(); ({ history } = mockedContext); - getEndpointPrivilegesInitialStateMock({ - canCreateArtifactsByPolicy: true, + useUserPrivilegesMock.mockReturnValue({ + endpointPrivileges: { + canCreateArtifactsByPolicy: true, + }, }); - render = async (externalPrivileges = true) => { + render = async (canWriteArtifact = true) => { await act(async () => { renderResult = mockedContext.render( { searchableFields={EVENT_FILTERS_SEARCHABLE_FIELDS} getArtifactPath={getEventFiltersListPath} getPolicyArtifactsPath={getPolicyEventFiltersPath} - externalPrivileges={externalPrivileges} + canWriteArtifact={canWriteArtifact} /> ); await waitFor(mockedApi.responseProvider.eventFiltersList); @@ -104,18 +116,13 @@ describe('Policy artifacts layout', () => { }); it('should render layout with no assigned artifacts data when there are artifacts', async () => { - mockedApi.responseProvider.eventFiltersList.mockImplementation( - (args?: { query: { filter: string } }) => { - if ( - !args || - args.query.filter !== parsePoliciesAndFilterToKql({ policies: [policyItem.id, 'all'] }) - ) { - return getFoundExceptionListItemSchemaMock(1); - } else { - return getFoundExceptionListItemSchemaMock(0); - } + mockedApi.responseProvider.eventFiltersList.mockImplementation((args?: MockedAPIArgs) => { + if (!isFilteredByPolicyQuery(args)) { + return getFoundExceptionListItemSchemaMock(1); + } else { + return getFoundExceptionListItemSchemaMock(0); } - ); + }); await render(); @@ -135,8 +142,10 @@ describe('Policy artifacts layout', () => { }); it('should hide `Assign artifacts to policy` on empty state with unassigned policies when downgraded to a gold or below license', async () => { - getEndpointPrivilegesInitialStateMock({ - canCreateArtifactsByPolicy: false, + useUserPrivilegesMock.mockReturnValue({ + endpointPrivileges: { + canCreateArtifactsByPolicy: false, + }, }); mockedApi.responseProvider.eventFiltersList.mockReturnValue( getFoundExceptionListItemSchemaMock(0) @@ -148,8 +157,10 @@ describe('Policy artifacts layout', () => { }); it('should hide the `Assign artifacts to policy` button license is downgraded to gold or below', async () => { - getEndpointPrivilegesInitialStateMock({ - canCreateArtifactsByPolicy: false, + useUserPrivilegesMock.mockReturnValue({ + endpointPrivileges: { + canCreateArtifactsByPolicy: false, + }, }); mockedApi.responseProvider.eventFiltersList.mockReturnValue( getFoundExceptionListItemSchemaMock(5) @@ -162,8 +173,10 @@ describe('Policy artifacts layout', () => { }); it('should hide the `Assign artifacts` flyout when license is downgraded to gold or below', async () => { - getEndpointPrivilegesInitialStateMock({ - canCreateArtifactsByPolicy: false, + useUserPrivilegesMock.mockReturnValue({ + endpointPrivileges: { + canCreateArtifactsByPolicy: false, + }, }); mockedApi.responseProvider.eventFiltersList.mockReturnValue( getFoundExceptionListItemSchemaMock(2) @@ -185,5 +198,26 @@ describe('Policy artifacts layout', () => { await render(false); expect(renderResult.queryByTestId('artifacts-assign-button')).toBeNull(); }); + it('should not display assign and manage artifacts buttons on empty state when there are artifacts', async () => { + mockedApi.responseProvider.eventFiltersList.mockImplementation((args?: MockedAPIArgs) => { + if (!isFilteredByPolicyQuery(args)) { + return getFoundExceptionListItemSchemaMock(1); + } else { + return getFoundExceptionListItemSchemaMock(0); + } + }); + await render(false); + expect(await renderResult.findByTestId('policy-artifacts-empty-unassigned')).not.toBeNull(); + expect(renderResult.queryByTestId('unassigned-assign-artifacts-button')).toBeNull(); + expect(renderResult.queryByTestId('unassigned-manage-artifacts-button')).toBeNull(); + }); + it('should not display manage artifacts button on empty state when there are no artifacts', async () => { + mockedApi.responseProvider.eventFiltersList.mockReturnValue( + getFoundExceptionListItemSchemaMock(0) + ); + await render(false); + expect(await renderResult.findByTestId('policy-artifacts-empty-unexisting')).not.toBeNull(); + expect(renderResult.queryByTestId('unexisting-manage-artifacts-button')).toBeNull(); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.tsx index dc45038d44904..baecdb8d03aab 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.tsx @@ -42,8 +42,8 @@ interface PolicyArtifactsLayoutProps { searchableFields: readonly string[]; getArtifactPath: (location?: Partial) => string; getPolicyArtifactsPath: (policyId: string) => string; - /** A boolean to check extra privileges for restricted actions, true when it's allowed, false when not */ - externalPrivileges?: boolean; + /** A boolean to check if has write artifact privilege or not */ + canWriteArtifact?: boolean; } export const PolicyArtifactsLayout = React.memo( ({ @@ -53,7 +53,7 @@ export const PolicyArtifactsLayout = React.memo( searchableFields, getArtifactPath, getPolicyArtifactsPath, - externalPrivileges = true, + canWriteArtifact = false, }) => { const exceptionsListApiClient = useMemo( () => getExceptionsListApiClient(), @@ -161,6 +161,7 @@ export const PolicyArtifactsLayout = React.memo( policyName={policyItem.name} listId={exceptionsListApiClient.listId} labels={labels} + canWriteArtifact={canWriteArtifact} getPolicyArtifactsPath={getPolicyArtifactsPath} getArtifactPath={getArtifactPath} /> @@ -169,6 +170,7 @@ export const PolicyArtifactsLayout = React.memo( policyId={policyItem.id} policyName={policyItem.name} labels={labels} + canWriteArtifact={canWriteArtifact} getPolicyArtifactsPath={getPolicyArtifactsPath} getArtifactPath={getArtifactPath} /> @@ -192,10 +194,10 @@ export const PolicyArtifactsLayout = React.memo( - {canCreateArtifactsByPolicy && externalPrivileges && assignToPolicyButton} + {canCreateArtifactsByPolicy && canWriteArtifact && assignToPolicyButton} - {canCreateArtifactsByPolicy && externalPrivileges && urlParams.show === 'list' && ( + {canCreateArtifactsByPolicy && canWriteArtifact && urlParams.show === 'list' && ( ( searchableFields={[...searchableFields]} labels={labels} onDeleteActionCallback={handleOnDeleteActionCallback} - externalPrivileges={externalPrivileges} + canWriteArtifact={canWriteArtifact} getPolicyArtifactsPath={getPolicyArtifactsPath} getArtifactPath={getArtifactPath} /> diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.test.tsx index 4d2231dbfc18e..2d5e74439f0f6 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.test.tsx @@ -39,7 +39,7 @@ const getDefaultQueryParameters = (customFilter: string | undefined = '') => ({ jest.setTimeout(10000); describe('Policy details artifacts list', () => { - let render: (externalPrivileges?: boolean) => Promise>; + let render: (canWriteArtifact?: boolean) => Promise>; let renderResult: ReturnType; let history: AppContextTestRender['history']; let mockedContext: AppContextTestRender; @@ -55,7 +55,7 @@ describe('Policy details artifacts list', () => { getEndpointPrivilegesInitialStateMock({ canCreateArtifactsByPolicy: true, }); - render = async (externalPrivileges = true) => { + render = async (canWriteArtifact = true) => { await act(async () => { renderResult = mockedContext.render( { searchableFields={[...SEARCHABLE_FIELDS]} labels={POLICY_ARTIFACT_LIST_LABELS} onDeleteActionCallback={handleOnDeleteActionCallbackMock} - externalPrivileges={externalPrivileges} + canWriteArtifact={canWriteArtifact} getPolicyArtifactsPath={getPolicyEventFiltersPath} getArtifactPath={getEventFiltersListPath} /> diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.tsx index 713aec1ca3103..22ddd31391977 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.tsx @@ -37,7 +37,7 @@ interface PolicyArtifactsListProps { getPolicyArtifactsPath: (policyId: string) => string; labels: typeof POLICY_ARTIFACT_LIST_LABELS; onDeleteActionCallback: (item: ExceptionListItemSchema) => void; - externalPrivileges?: boolean; + canWriteArtifact?: boolean; } export const PolicyArtifactsList = React.memo( @@ -49,7 +49,7 @@ export const PolicyArtifactsList = React.memo( getPolicyArtifactsPath, labels, onDeleteActionCallback, - externalPrivileges = true, + canWriteArtifact = false, }) => { useOldUrlSearchPaginationReplace(); const { getAppUrl } = useAppUrl(); @@ -150,7 +150,7 @@ export const PolicyArtifactsList = React.memo( return { expanded: expandedItemsMap.get(item.id) || false, actions: - canCreateArtifactsByPolicy && externalPrivileges + canCreateArtifactsByPolicy && canWriteArtifact ? [fullDetailsAction, deleteAction] : [fullDetailsAction], policies: artifactCardPolicies, @@ -160,7 +160,7 @@ export const PolicyArtifactsList = React.memo( artifactCardPolicies, canCreateArtifactsByPolicy, expandedItemsMap, - externalPrivileges, + canWriteArtifact, getAppUrl, getArtifactPath, labels.listFullDetailsActionTitle, diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/translations.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/translations.ts index 84331c6cec95c..95716eec16a4b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/translations.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/translations.ts @@ -67,6 +67,7 @@ export type PolicyArtifactsPageRequiredLabels = Pick< | 'emptyUnassignedMessage' | 'emptyUnassignedPrimaryActionButtonTitle' | 'emptyUnassignedSecondaryActionButtonTitle' + | 'emptyUnassignedNoPrivilegesMessage' | 'emptyUnexistingTitle' | 'emptyUnexistingMessage' | 'emptyUnexistingPrimaryActionButtonTitle' diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx index eab38e3cdd7b5..3505f242b8ca9 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import { waitFor } from '@testing-library/react'; import { mount } from 'enzyme'; import React from 'react'; import { AGENT_API_ROUTES, PACKAGE_POLICY_API_ROOT } from '@kbn/fleet-plugin/common'; @@ -16,12 +15,19 @@ import { createAppRootMockRenderer, resetReactDomCreatePortalMock, } from '../../../../common/mock/endpoint'; -import { getEndpointListPath, getPoliciesPath, getPolicyDetailPath } from '../../../common/routing'; +import { + getEndpointListPath, + getPoliciesPath, + getPolicyBlocklistsPath, + getPolicyDetailPath, + getPolicyEventFiltersPath, + getPolicyHostIsolationExceptionsPath, + getPolicyTrustedAppsPath, +} from '../../../common/routing'; import { policyListApiPathHandlers } from '../store/test_mock_utils'; import { PolicyDetails } from './policy_details'; import { APP_UI_ID } from '../../../../../common/constants'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; -import { exceptionsFindHttpMocks } from '../../../mocks/exceptions_list_http_mocks'; jest.mock('./policy_forms/components/policy_form_layout', () => ({ PolicyFormLayout: () => <>, @@ -215,61 +221,53 @@ describe('Policy Details', () => { expect(tab.text()).toBe('Host isolation exceptions'); }); - describe('without canIsolateHost permissions', () => { - let findExceptionsApiHttpMock: ReturnType; - - beforeEach(() => { + describe('without required permissions', () => { + const renderWithPrivilege = async (privilege: string) => { useUserPrivilegesMock.mockReturnValue({ endpointPrivileges: { loading: false, - canIsolateHost: false, + [privilege]: false, }, }); - - findExceptionsApiHttpMock = exceptionsFindHttpMocks(http); - }); - - it('should not display the host isolation exceptions tab with no privileges and no assigned exceptions', async () => { - findExceptionsApiHttpMock.responseProvider.exceptionsFind.mockReturnValue({ - data: [], - total: 0, - page: 1, - per_page: 100, - }); policyView = render(); await asyncActions; policyView.update(); - await waitFor(() => { - expect(findExceptionsApiHttpMock.responseProvider.exceptionsFind).toHaveBeenCalled(); - }); - expect(policyView.find('button#hostIsolationExceptions')).toHaveLength(0); - }); - - it('should not display the host isolation exceptions tab with no privileges and no data', async () => { - findExceptionsApiHttpMock.responseProvider.exceptionsFind.mockReturnValue({ - data: [], - total: 0, - page: 1, - per_page: 100, - }); - policyView = render(); - await asyncActions; - policyView.update(); - await waitFor(() => { - expect(findExceptionsApiHttpMock.responseProvider.exceptionsFind).toHaveBeenCalled(); - }); - expect(policyView.find('button#hostIsolationExceptions')).toHaveLength(0); - }); + }; - it('should display the host isolation exceptions tab with no privileges if there are assigned exceptions', async () => { - policyView = render(); - await asyncActions; - policyView.update(); - await waitFor(() => { - expect(findExceptionsApiHttpMock.responseProvider.exceptionsFind).toHaveBeenCalled(); - }); - expect(policyView.find('button#hostIsolationExceptions')).toHaveLength(1); - }); + it.each([ + ['trusted apps', 'canReadTrustedApplications', 'trustedApps'], + ['event filters', 'canReadEventFilters', 'eventFilters'], + ['host isolation exeptions', 'canReadHostIsolationExceptions', 'hostIsolationExceptions'], + ['blocklist', 'canReadBlocklist', 'blocklists'], + ])( + 'should not display the %s tab with no privileges', + async (_: string, privilege: string, selector: string) => { + await renderWithPrivilege(privilege); + expect(policyView.find(`button#${selector}`)).toHaveLength(0); + } + ); + + it.each([ + ['trusted apps', 'canReadTrustedApplications', getPolicyTrustedAppsPath('1')], + ['event filters', 'canReadEventFilters', getPolicyEventFiltersPath('1')], + [ + 'host isolation exeptions', + 'canReadHostIsolationExceptions', + getPolicyHostIsolationExceptionsPath('1'), + ], + ['blocklist', 'canReadBlocklist', getPolicyBlocklistsPath('1')], + ])( + 'should redirect to policy details when no %s required privileges', + async (_: string, privilege: string, path: string) => { + history.push(path); + await renderWithPrivilege(privilege); + expect(history.location.pathname).toBe(policyDetailsPathUrl); + expect(coreStart.notifications.toasts.addDanger).toHaveBeenCalledTimes(1); + expect(coreStart.notifications.toasts.addDanger).toHaveBeenCalledWith( + 'You do not have the required Kibana permissions to use the given artifact.' + ); + } + ); }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/blocklists_translations.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/blocklists_translations.ts index 4222029cd12fa..ca09d768f2051 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/blocklists_translations.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/blocklists_translations.ts @@ -104,6 +104,14 @@ export const POLICY_ARTIFACT_BLOCKLISTS_LABELS = Object.freeze({ defaultMessage: 'Manage blocklist entries', } ), + emptyUnassignedNoPrivilegesMessage: (policyName: string): string => + i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklist.empty.unassigned.noPrivileges.content', + { + defaultMessage: 'There are currently no blocklist entries assigned to {policyName}.', + values: { policyName }, + } + ), emptyUnexistingTitle: i18n.translate( 'xpack.securitySolution.endpoint.policy.blocklist.empty.unexisting.title', { defaultMessage: 'No blocklists entries exist' } diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/event_filters_translations.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/event_filters_translations.ts index 8d9f5863fea31..090474abf1fa6 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/event_filters_translations.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/event_filters_translations.ts @@ -104,6 +104,14 @@ export const POLICY_ARTIFACT_EVENT_FILTERS_LABELS = Object.freeze({ defaultMessage: 'Manage event filters', } ), + emptyUnassignedNoPrivilegesMessage: (policyName: string): string => + i18n.translate( + 'xpack.securitySolution.endpoint.policy.eventFilters.empty.unassigned.noPrivileges.content', + { + defaultMessage: 'There are currently no event filters assigned to {policyName}', + values: { policyName }, + } + ), emptyUnexistingTitle: i18n.translate( 'xpack.securitySolution.endpoint.policy.eventFilters.empty.unexisting.title', { defaultMessage: 'No event filters exist' } diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/host_isolation_exceptions_translations.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/host_isolation_exceptions_translations.ts index a6066b4df29b9..f2ec049940510 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/host_isolation_exceptions_translations.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/host_isolation_exceptions_translations.ts @@ -110,6 +110,15 @@ export const POLICY_ARTIFACT_HOST_ISOLATION_EXCEPTIONS_LABELS = Object.freeze({ defaultMessage: 'Manage host isolation exceptions', } ), + emptyUnassignedNoPrivilegesMessage: (policyName: string): string => + i18n.translate( + 'xpack.securitySolution.endpoint.policy.hostIsolationException.empty.unassigned.noPrivileges.content', + { + defaultMessage: + 'There are currently no host isolation exceptions assigned to {policyName}.', + values: { policyName }, + } + ), emptyUnexistingTitle: i18n.translate( 'xpack.securitySolution.endpoint.policy.hostIsolationException.empty.unexisting.title', { defaultMessage: 'No host isolation exceptions exist' } diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/policy_tabs.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/policy_tabs.tsx index 9728e89e539e8..e16d5fcb392a8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/policy_tabs.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/policy_tabs.tsx @@ -24,7 +24,7 @@ import { getBlocklistsListPath, getPolicyBlocklistsPath, } from '../../../../common/routing'; -import { useHttp } from '../../../../../common/lib/kibana'; +import { useHttp, useToasts } from '../../../../../common/lib/kibana'; import { ManagementPageLoader } from '../../../../components/management_page_loader'; import { isOnHostIsolationExceptionsView, @@ -51,7 +51,6 @@ import { SEARCHABLE_FIELDS as EVENT_FILTERS_SEARCHABLE_FIELDS } from '../../../e import { SEARCHABLE_FIELDS as HOST_ISOLATION_EXCEPTIONS_SEARCHABLE_FIELDS } from '../../../host_isolation_exceptions/constants'; import { SEARCHABLE_FIELDS as BLOCKLISTS_SEARCHABLE_FIELDS } from '../../../blocklist/constants'; import type { PolicyDetailsRouteState } from '../../../../../../common/endpoint/types'; -import { useListArtifact } from '../../../../hooks/artifacts'; enum PolicyTabKeys { SETTINGS = 'settings', @@ -70,40 +69,57 @@ interface PolicyTab { export const PolicyTabs = React.memo(() => { const history = useHistory(); const http = useHttp(); + const toasts = useToasts(); + const isInSettingsTab = usePolicyDetailsSelector(isOnPolicyFormView); const isInTrustedAppsTab = usePolicyDetailsSelector(isOnPolicyTrustedAppsView); - const isInEventFilters = usePolicyDetailsSelector(isOnPolicyEventFiltersView); + const isInEventFiltersTab = usePolicyDetailsSelector(isOnPolicyEventFiltersView); const isInHostIsolationExceptionsTab = usePolicyDetailsSelector(isOnHostIsolationExceptionsView); const isInBlocklistsTab = usePolicyDetailsSelector(isOnBlocklistsView); const policyId = usePolicyDetailsSelector(policyIdFromParams); const policyItem = usePolicyDetailsSelector(policyDetails); - const privileges = useUserPrivileges().endpointPrivileges; + const { + canReadTrustedApplications, + canWriteTrustedApplications, + canReadEventFilters, + canWriteEventFilters, + canReadHostIsolationExceptions, + canWriteHostIsolationExceptions, + canReadBlocklist, + canWriteBlocklist, + loading: privilegesLoading, + } = useUserPrivileges().endpointPrivileges; const { state: routeState = {} } = useLocation(); - const allPolicyHostIsolationExceptionsListRequest = useListArtifact( - HostIsolationExceptionsApiClient.getInstance(http), - { - page: 1, - perPage: 100, - policies: [policyId, 'all'], - }, - HOST_ISOLATION_EXCEPTIONS_SEARCHABLE_FIELDS, - { - enabled: !privileges.loading && !privileges.canIsolateHost, - } - ); - - const canSeeHostIsolationExceptions = - privileges.canIsolateHost || - (allPolicyHostIsolationExceptionsListRequest.isFetched && - allPolicyHostIsolationExceptionsListRequest.data?.total !== 0); - - // move the use out of this route if they can't access it + // move the user out of this route if they can't access it useEffect(() => { - if (isInHostIsolationExceptionsTab && !canSeeHostIsolationExceptions) { + if ( + (isInTrustedAppsTab && !canReadTrustedApplications) || + (isInEventFiltersTab && !canReadEventFilters) || + (isInHostIsolationExceptionsTab && !canReadHostIsolationExceptions) || + (isInBlocklistsTab && !canReadBlocklist) + ) { history.replace(getPolicyDetailPath(policyId)); + toasts.addDanger( + i18n.translate('xpack.securitySolution.policyDetails.missingArtifactAccess', { + defaultMessage: + 'You do not have the required Kibana permissions to use the given artifact.', + }) + ); } - }, [canSeeHostIsolationExceptions, history, isInHostIsolationExceptionsTab, policyId]); + }, [ + canReadBlocklist, + canReadEventFilters, + canReadHostIsolationExceptions, + canReadTrustedApplications, + history, + isInBlocklistsTab, + isInEventFiltersTab, + isInHostIsolationExceptionsTab, + isInTrustedAppsTab, + policyId, + toasts, + ]); const getTrustedAppsApiClientInstance = useCallback( () => TrustedAppsApiClient.getInstance(http), @@ -183,45 +199,57 @@ export const PolicyTabs = React.memo(() => { ), }, - [PolicyTabKeys.TRUSTED_APPS]: { - id: PolicyTabKeys.TRUSTED_APPS, - name: i18n.translate('xpack.securitySolution.endpoint.policy.details.tabs.trustedApps', { - defaultMessage: 'Trusted applications', - }), - content: ( - <> - - - - ), - }, - [PolicyTabKeys.EVENT_FILTERS]: { - id: PolicyTabKeys.EVENT_FILTERS, - name: i18n.translate('xpack.securitySolution.endpoint.policy.details.tabs.eventFilters', { - defaultMessage: 'Event filters', - }), - content: ( - <> - - - - ), - }, - [PolicyTabKeys.HOST_ISOLATION_EXCEPTIONS]: canSeeHostIsolationExceptions + [PolicyTabKeys.TRUSTED_APPS]: canReadTrustedApplications + ? { + id: PolicyTabKeys.TRUSTED_APPS, + name: i18n.translate( + 'xpack.securitySolution.endpoint.policy.details.tabs.trustedApps', + { + defaultMessage: 'Trusted applications', + } + ), + content: ( + <> + + + + ), + } + : undefined, + [PolicyTabKeys.EVENT_FILTERS]: canReadEventFilters + ? { + id: PolicyTabKeys.EVENT_FILTERS, + name: i18n.translate( + 'xpack.securitySolution.endpoint.policy.details.tabs.eventFilters', + { + defaultMessage: 'Event filters', + } + ), + content: ( + <> + + + + ), + } + : undefined, + [PolicyTabKeys.HOST_ISOLATION_EXCEPTIONS]: canReadHostIsolationExceptions ? { id: PolicyTabKeys.HOST_ISOLATION_EXCEPTIONS, name: i18n.translate( @@ -240,40 +268,49 @@ export const PolicyTabs = React.memo(() => { searchableFields={HOST_ISOLATION_EXCEPTIONS_SEARCHABLE_FIELDS} getArtifactPath={getHostIsolationExceptionsListPath} getPolicyArtifactsPath={getPolicyHostIsolationExceptionsPath} - externalPrivileges={privileges.canIsolateHost} + canWriteArtifact={canWriteHostIsolationExceptions} + /> + + ), + } + : undefined, + [PolicyTabKeys.BLOCKLISTS]: canReadBlocklist + ? { + id: PolicyTabKeys.BLOCKLISTS, + name: i18n.translate('xpack.securitySolution.endpoint.policy.details.tabs.blocklists', { + defaultMessage: 'Blocklist', + }), + content: ( + <> + + ), } : undefined, - [PolicyTabKeys.BLOCKLISTS]: { - id: PolicyTabKeys.BLOCKLISTS, - name: i18n.translate('xpack.securitySolution.endpoint.policy.details.tabs.blocklists', { - defaultMessage: 'Blocklist', - }), - content: ( - <> - - - - ), - }, }; }, [ - canSeeHostIsolationExceptions, + canReadTrustedApplications, + canWriteTrustedApplications, + canReadEventFilters, + canWriteEventFilters, + canReadHostIsolationExceptions, + canWriteHostIsolationExceptions, + canReadBlocklist, + canWriteBlocklist, getEventFiltersApiClientInstance, getHostIsolationExceptionsApiClientInstance, getBlocklistsApiClientInstance, getTrustedAppsApiClientInstance, policyItem, - privileges.canIsolateHost, ]); // convert tabs object into an array EuiTabbedContent can understand @@ -290,7 +327,7 @@ export const PolicyTabs = React.memo(() => { selectedTab = tabs[PolicyTabKeys.SETTINGS]; } else if (isInTrustedAppsTab) { selectedTab = tabs[PolicyTabKeys.TRUSTED_APPS]; - } else if (isInEventFilters) { + } else if (isInEventFiltersTab) { selectedTab = tabs[PolicyTabKeys.EVENT_FILTERS]; } else if (isInHostIsolationExceptionsTab) { selectedTab = tabs[PolicyTabKeys.HOST_ISOLATION_EXCEPTIONS]; @@ -303,7 +340,7 @@ export const PolicyTabs = React.memo(() => { tabs, isInSettingsTab, isInTrustedAppsTab, - isInEventFilters, + isInEventFiltersTab, isInHostIsolationExceptionsTab, isInBlocklistsTab, ]); @@ -334,11 +371,8 @@ export const PolicyTabs = React.memo(() => { ); // show loader for privileges validation - if ( - isInHostIsolationExceptionsTab && - (privileges.loading || allPolicyHostIsolationExceptionsListRequest.isFetching) - ) { - return ; + if (privilegesLoading) { + return ; } return ( diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/trusted_apps_translations.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/trusted_apps_translations.ts index 18a982bd7fa95..505ee0655e4ba 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/trusted_apps_translations.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/trusted_apps_translations.ts @@ -104,6 +104,14 @@ export const POLICY_ARTIFACT_TRUSTED_APPS_LABELS = Object.freeze({ defaultMessage: 'Manage trusted applications', } ), + emptyUnassignedNoPrivilegesMessage: (policyName: string): string => + i18n.translate( + 'xpack.securitySolution.endpoint.policy.trustedApps.empty.unassigned.noPrivileges.content', + { + defaultMessage: 'There are currently no trusted applications assigned to {policyName}.', + values: { policyName }, + } + ), emptyUnexistingTitle: i18n.translate( 'xpack.securitySolution.endpoint.policy.trustedApps.empty.unexisting.title', { defaultMessage: 'No trusted applications exist' }