From d3b4d39099d77b2b1fba2902ca1fc886dcdeded1 Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Thu, 1 Dec 2022 12:41:53 -0500 Subject: [PATCH] [Security Solution][Endpoint] Add RBAC to Endpoint Policy List and Policy Details pages (#146480) ## Summary - The following changes were done in support of RBAC for the policy management from security solution: - Pages are only accessible if user has `read` permissions - If user does not have `read` or `write` permissions, the link to the Policy list is remove from the Security Solution management page - If user ONLY has `read`, then the Policy Details save button is removed and all form controls (ex. switches, checkboxes, etc) are disabled - If user does not have `read` permissions to the Endpoint list page, then the policy list `Endpoints` column is displayed as plain text (no link) - Fixes a bug with the `Cancel` button on the Policy Details, which was redirecting the user by default to the Endpoint List - correct behavior is to redirect to the policy list by default --- .../public/management/links.test.ts | 273 ++++++++++-------- .../public/management/links.ts | 8 +- .../pages/endpoint_hosts/view/index.tsx | 23 +- .../antivirus_registration_form/index.tsx | 3 + .../attack_surface_reduction_form/index.tsx | 3 + .../view/components/events_form/index.tsx | 8 +- .../view/components/policy_endpoint_count.tsx | 3 +- .../pages/policy/view/policy_advanced.tsx | 3 + .../components/policy_form_layout.test.tsx | 49 +++- .../components/policy_form_layout.tsx | 52 ++-- .../components/protection_radio.tsx | 4 +- .../components/protection_switch.tsx | 3 + .../components/user_notification.tsx | 5 +- .../view/policy_forms/protections/malware.tsx | 6 +- .../pages/policy/view/policy_list.tsx | 6 +- 15 files changed, 295 insertions(+), 154 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/links.test.ts b/x-pack/plugins/security_solution/public/management/links.test.ts index 83e8b43d674a7..aa3f3ba93e9da 100644 --- a/x-pack/plugins/security_solution/public/management/links.test.ts +++ b/x-pack/plugins/security_solution/public/management/links.test.ts @@ -12,10 +12,20 @@ import { SecurityPageName } from '../app/types'; import { calculateEndpointAuthz } from '../../common/endpoint/service/authz'; import type { StartPlugins } from '../types'; -import { links, getManagementFilteredLinks } from './links'; +import { getManagementFilteredLinks, links } from './links'; import { allowedExperimentalValues } from '../../common/experimental_features'; import { ExperimentalFeaturesService } from '../common/experimental_features_service'; import { getEndpointAuthzInitialStateMock } from '../../common/endpoint/service/authz/mocks'; +import { licenseService as _licenseService } from '../common/hooks/use_license'; +import type { LicenseService } from '../../common/license'; +import { createLicenseServiceMock } from '../../common/license/mocks'; +import type { FleetAuthz } from '@kbn/fleet-plugin/common'; +import { createFleetAuthzMock } from '@kbn/fleet-plugin/common'; +import type { DeepPartial } from '@kbn/utility-types'; +import { merge } from 'lodash'; +import { ENDPOINT_ARTIFACT_LISTS } from '@kbn/securitysolution-list-constants'; + +jest.mock('../common/hooks/use_license'); jest.mock('../../common/endpoint/service/authz', () => { const originalModule = jest.requireActual('../../common/endpoint/service/authz'); @@ -27,9 +37,10 @@ jest.mock('../../common/endpoint/service/authz', () => { jest.mock('../common/lib/kibana'); +const licenseServiceMock = _licenseService as jest.Mocked; + describe('links', () => { let coreMockStarted: ReturnType; - let getPlugins: (roles: string[]) => StartPlugins; let fakeHttpServices: jest.Mocked; const getLinksWithout = (...excludedLinks: SecurityPageName[]) => ({ @@ -37,6 +48,22 @@ describe('links', () => { links: links.links?.filter((link) => !excludedLinks.includes(link.id)), }); + const getPlugins = ( + roles: string[], + fleetAuthzOverrides: DeepPartial = {} + ): StartPlugins => { + return { + security: { + authc: { + getCurrentUser: jest.fn().mockReturnValue({ roles }), + }, + }, + fleet: { + authz: merge(createFleetAuthzMock(), fleetAuthzOverrides), + }, + } as unknown as StartPlugins; + }; + beforeAll(() => { ExperimentalFeaturesService.init({ experimentalFeatures: { ...allowedExperimentalValues }, @@ -46,22 +73,11 @@ describe('links', () => { beforeEach(() => { coreMockStarted = coreMock.createStart(); fakeHttpServices = coreMockStarted.http as jest.Mocked; + }); + + afterEach(() => { fakeHttpServices.get.mockClear(); - getPlugins = (roles) => - ({ - security: { - authc: { - getCurrentUser: jest.fn().mockReturnValue({ roles }), - }, - }, - fleet: { - authz: { - fleet: { - all: true, - }, - }, - }, - } as unknown as StartPlugins); + Object.assign(licenseServiceMock, createLicenseServiceMock()); }); it('should return all links for user with all sub-feature privileges', async () => { @@ -88,146 +104,173 @@ describe('links', () => { }); }); - // todo: these tests should be updated, because in the end, showing/hiding HIE depends on nothing - // else but the mock return of `calculateEndpointAuthz`. - // These tests should check what is the value of `hasHostIsolationExceptions` which is passed to - // `calculateEndpointAuthz`. describe('Host Isolation Exception', () => { - it('should return all but HIE when NO isolation permission due to privilege', async () => { - (calculateEndpointAuthz as jest.Mock).mockReturnValue({ - canIsolateHost: false, - canUnIsolateHost: false, - canAccessEndpointManagement: true, - canReadActionsLogManagement: true, - canReadEndpointList: true, - canReadTrustedApplications: true, - canReadEventFilters: true, - }); + it('should NOT return HIE if `canReadHostIsolationExceptions` is false', async () => { + (calculateEndpointAuthz as jest.Mock).mockReturnValue( + getEndpointAuthzInitialStateMock({ canReadHostIsolationExceptions: false }) + ); const filteredLinks = await getManagementFilteredLinks( coreMockStarted, getPlugins(['superuser']) ); + expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.hostIsolationExceptions)); }); - it('should return all but HIE when NO isolation permission due to license and NO host isolation exceptions entry', async () => { - (calculateEndpointAuthz as jest.Mock).mockReturnValue({ - canIsolateHost: false, - canUnIsolateHost: true, - canAccessEndpointManagement: true, - canReadActionsLogManagement: true, - canReadEndpointList: true, - canReadTrustedApplications: true, - canReadEventFilters: true, - }); + it('should NOT return HIE if license is lower than Enterprise and NO HIE entries exist', async () => { + (calculateEndpointAuthz as jest.Mock).mockReturnValue( + getEndpointAuthzInitialStateMock({ canReadHostIsolationExceptions: false }) + ); + fakeHttpServices.get.mockResolvedValue({ total: 0 }); + licenseServiceMock.isPlatinumPlus.mockReturnValue(false); + ExperimentalFeaturesService.init({ + experimentalFeatures: { ...allowedExperimentalValues, endpointRbacEnabled: true }, + }); const filteredLinks = await getManagementFilteredLinks( coreMockStarted, - getPlugins(['superuser']) + getPlugins([], { + packagePrivileges: { + endpoint: { + actions: { + readHostIsolationExceptions: { + executePackageAction: true, + }, + }, + }, + }, + }) ); - expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.hostIsolationExceptions)); - }); - it('should return all but HIE when HAS isolation permission AND has HIE entry but not superuser', async () => { - (calculateEndpointAuthz as jest.Mock).mockReturnValue({ - canIsolateHost: false, - canUnIsolateHost: true, - canAccessEndpointManagement: false, - canReadActionsLogManagement: true, - canReadEndpointList: true, - canReadTrustedApplications: true, - canReadEventFilters: true, + expect(fakeHttpServices.get).toHaveBeenCalledWith('/api/exception_lists/items/_find', { + query: expect.objectContaining({ + list_id: [ENDPOINT_ARTIFACT_LISTS.hostIsolationExceptions.id], + }), }); - fakeHttpServices.get.mockResolvedValue({ total: 1 }); - - const filteredLinks = await getManagementFilteredLinks( - coreMockStarted, - getPlugins(['superuser']) + expect(calculateEndpointAuthz as jest.Mock).toHaveBeenLastCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + false ); expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.hostIsolationExceptions)); }); - it('should return all when NO isolation permission due to license but HAS at least one host isolation exceptions entry', async () => { + it('should return HIE if license is lower than Enterprise, but HIE entries exist', async () => { (calculateEndpointAuthz as jest.Mock).mockReturnValue( - getEndpointAuthzInitialStateMock({ - canIsolateHost: false, - }) + getEndpointAuthzInitialStateMock({ canReadHostIsolationExceptions: true }) ); - fakeHttpServices.get.mockResolvedValue({ total: 1 }); + + fakeHttpServices.get.mockResolvedValue({ total: 100 }); + licenseServiceMock.isPlatinumPlus.mockReturnValue(false); + ExperimentalFeaturesService.init({ + experimentalFeatures: { ...allowedExperimentalValues, endpointRbacEnabled: true }, + }); const filteredLinks = await getManagementFilteredLinks( coreMockStarted, - getPlugins(['superuser']) + getPlugins([], { + packagePrivileges: { + endpoint: { + actions: { + readHostIsolationExceptions: { + executePackageAction: true, + }, + }, + }, + }, + }) ); - expect(filteredLinks).toEqual(links); + + expect(fakeHttpServices.get).toHaveBeenCalledWith('/api/exception_lists/items/_find', { + query: expect.objectContaining({ + list_id: [ENDPOINT_ARTIFACT_LISTS.hostIsolationExceptions.id], + }), + }); + expect(calculateEndpointAuthz as jest.Mock).toHaveBeenLastCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + true + ); + expect(filteredLinks).toEqual(getLinksWithout()); }); + }); - it('should not affect showing Action Log if getting from HIE API throws error', async () => { - (calculateEndpointAuthz as jest.Mock).mockReturnValue({ - canIsolateHost: false, - canUnIsolateHost: true, - canReadActionsLogManagement: true, - canReadEndpointList: true, - canReadTrustedApplications: true, - canReadEventFilters: true, + // this can be removed after removing endpointRbacEnabled feature flag + describe('without endpointRbacEnabled', () => { + beforeAll(() => { + ExperimentalFeaturesService.init({ + experimentalFeatures: { ...allowedExperimentalValues, endpointRbacEnabled: false }, }); - fakeHttpServices.get.mockRejectedValue(new Error()); + }); - const filteredLinks = await getManagementFilteredLinks( - coreMockStarted, - getPlugins(['superuser']) - ); - expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.hostIsolationExceptions)); + it('shows Trusted Applications for non-superuser, too', async () => { + (calculateEndpointAuthz as jest.Mock).mockReturnValue(getEndpointAuthzInitialStateMock()); + + const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins([])); + + expect(filteredLinks).toEqual(links); }); + }); - it('should not affect hiding Action Log if getting from HIE API throws error', async () => { - (calculateEndpointAuthz as jest.Mock).mockReturnValue({ - canIsolateHost: false, - canUnIsolateHost: true, - canReadActionsLogManagement: false, - canReadEndpointList: true, - canReadTrustedApplications: true, - canReadEventFilters: true, + // this can be the default after removing endpointRbacEnabled feature flag + describe('with endpointRbacEnabled', () => { + beforeAll(() => { + ExperimentalFeaturesService.init({ + experimentalFeatures: { ...allowedExperimentalValues, endpointRbacEnabled: true }, }); - fakeHttpServices.get.mockRejectedValue(new Error()); + }); - const filteredLinks = await getManagementFilteredLinks( - coreMockStarted, - getPlugins(['superuser']) - ); - expect(filteredLinks).toEqual( - getLinksWithout( - SecurityPageName.hostIsolationExceptions, - SecurityPageName.responseActionsHistory - ) + it('should hide Trusted Applications for user without privilege', async () => { + (calculateEndpointAuthz as jest.Mock).mockReturnValue( + getEndpointAuthzInitialStateMock({ + canReadTrustedApplications: false, + }) ); + + const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins([])); + + expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.trustedApps)); }); - }); - it('should hide Trusted Applications for user without privilege', async () => { - (calculateEndpointAuthz as jest.Mock).mockReturnValue( - getEndpointAuthzInitialStateMock({ - canReadTrustedApplications: false, - }) - ); + it('should show Trusted Applications for user with privilege', async () => { + (calculateEndpointAuthz as jest.Mock).mockReturnValue(getEndpointAuthzInitialStateMock()); - const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins([])); + const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins([])); - expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.trustedApps)); - }); + expect(filteredLinks).toEqual(links); + }); + + it('should hide Event Filters for user without privilege', async () => { + (calculateEndpointAuthz as jest.Mock).mockReturnValue( + getEndpointAuthzInitialStateMock({ + canReadEventFilters: false, + }) + ); - it('should hide Event Filters for user without privilege', async () => { - (calculateEndpointAuthz as jest.Mock).mockReturnValue( - getEndpointAuthzInitialStateMock({ - canReadEventFilters: false, - }) - ); + const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins([])); - const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins([])); + expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.eventFilters)); + }); + + it('should NOT return policies if `canReadPolicyManagement` is `false`', async () => { + (calculateEndpointAuthz as jest.Mock).mockReturnValue( + getEndpointAuthzInitialStateMock({ + canReadPolicyManagement: false, + }) + ); - expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.eventFilters)); + const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins([])); + + expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.policies)); + }); }); describe('Endpoint List', () => { diff --git a/x-pack/plugins/security_solution/public/management/links.ts b/x-pack/plugins/security_solution/public/management/links.ts index fde26e39aead4..7004edb28fe47 100644 --- a/x-pack/plugins/security_solution/public/management/links.ts +++ b/x-pack/plugins/security_solution/public/management/links.ts @@ -61,7 +61,6 @@ import { IconSiemRules } from './icons/siem_rules'; import { IconTrustedApplications } from './icons/trusted_applications'; import { HostIsolationExceptionsApiClient } from './pages/host_isolation_exceptions/host_isolation_exceptions_api_client'; import { ExperimentalFeaturesService } from '../common/experimental_features_service'; -import { KibanaServices } from '../common/lib/kibana'; const categories = [ { @@ -269,7 +268,7 @@ export const getManagementFilteredLinks = async ( ) ) { hasHostIsolationExceptions = await checkArtifactHasData( - HostIsolationExceptionsApiClient.getInstance(KibanaServices.get().http) + HostIsolationExceptionsApiClient.getInstance(core.http) ); } @@ -279,6 +278,7 @@ export const getManagementFilteredLinks = async ( canReadEndpointList, canReadTrustedApplications, canReadEventFilters, + canReadPolicyManagement, } = fleetAuthz ? calculateEndpointAuthz( licenseService, @@ -294,6 +294,10 @@ export const getManagementFilteredLinks = async ( linksToExclude.push(SecurityPageName.endpoints); } + if (!canReadPolicyManagement) { + linksToExclude.push(SecurityPageName.policies); + } + if (!canReadActionsLogManagement) { linksToExclude.push(SecurityPageName.responseActionsHistory); } diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index ce53039039b3f..908f66fb3802c 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -135,6 +135,7 @@ export const EndpointList = () => { const { canReadEndpointList, canAccessFleet, + canReadPolicyManagement, loading: endpointPrivilegesLoading, } = useUserPrivileges().endpointPrivileges; const { search } = useFormatUrl(SecurityPageName.administration); @@ -389,14 +390,18 @@ export const EndpointList = () => { return ( <> - - {policy.name} - + {canReadPolicyManagement ? ( + + {policy.name} + + ) : ( + <>{policy.name} + )} {policy.endpoint_policy_version && ( { ], }, ]; - }, [queryParams, search, getAppUrl, backToEndpointList, PAD_LEFT]); + }, [queryParams, search, getAppUrl, canReadPolicyManagement, backToEndpointList, PAD_LEFT]); const renderTableOrEmptyState = useMemo(() => { if (endpointsExist || areEndpointsEnrolling) { diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/components/antivirus_registration_form/index.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/components/antivirus_registration_form/index.tsx index c02969993e62d..76059dae08bc4 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/components/antivirus_registration_form/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/components/antivirus_registration_form/index.tsx @@ -11,6 +11,7 @@ import { i18n } from '@kbn/i18n'; import { EuiSpacer, EuiSwitch, EuiText } from '@elastic/eui'; import { OperatingSystem } from '@kbn/securitysolution-utils'; +import { useUserPrivileges } from '../../../../../../common/components/user_privileges'; import { isAntivirusRegistrationEnabled } from '../../../store/policy_details/selectors'; import { usePolicyDetailsSelector } from '../../policy_hooks'; import { ConfigForm } from '../config_form'; @@ -41,6 +42,7 @@ const TRANSLATIONS: Readonly<{ [K in 'title' | 'description' | 'label']: string export const AntivirusRegistrationForm = memo(() => { const antivirusRegistrationEnabled = usePolicyDetailsSelector(isAntivirusRegistrationEnabled); const dispatch = useDispatch(); + const { canWritePolicyManagement } = useUserPrivileges().endpointPrivileges; const handleSwitchChange = useCallback( (event) => @@ -68,6 +70,7 @@ export const AntivirusRegistrationForm = memo(() => { label={TRANSLATIONS.label} checked={antivirusRegistrationEnabled} onChange={handleSwitchChange} + disabled={!canWritePolicyManagement} /> ); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/components/attack_surface_reduction_form/index.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/components/attack_surface_reduction_form/index.tsx index 525be79b7f785..e1c01c552468b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/components/attack_surface_reduction_form/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/components/attack_surface_reduction_form/index.tsx @@ -11,6 +11,7 @@ import { i18n } from '@kbn/i18n'; import { EuiSwitch } from '@elastic/eui'; import { OperatingSystem } from '@kbn/securitysolution-utils'; +import { useUserPrivileges } from '../../../../../../common/components/user_privileges'; import { isCredentialHardeningEnabled } from '../../../store/policy_details/selectors'; import { usePolicyDetailsSelector } from '../../policy_hooks'; import { ConfigForm } from '../config_form'; @@ -33,6 +34,7 @@ const TRANSLATIONS: Readonly<{ [K in 'title' | 'label']: string }> = { export const AttackSurfaceReductionForm = memo(() => { const credentialHardeningEnabled = usePolicyDetailsSelector(isCredentialHardeningEnabled); const dispatch = useDispatch(); + const { canWritePolicyManagement } = useUserPrivileges().endpointPrivileges; const handleSwitchChange = useCallback( (event) => @@ -51,6 +53,7 @@ export const AttackSurfaceReductionForm = memo(() => { label={TRANSLATIONS.label} checked={credentialHardeningEnabled} onChange={handleSwitchChange} + disabled={!canWritePolicyManagement} /> ); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/components/events_form/index.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/components/events_form/index.tsx index 7ae421c5a253d..f497b59394b10 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/components/events_form/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/components/events_form/index.tsx @@ -19,6 +19,7 @@ import { } from '@elastic/eui'; import { OperatingSystem } from '@kbn/securitysolution-utils'; import { ThemeContext } from 'styled-components'; +import { useUserPrivileges } from '../../../../../../common/components/user_privileges'; import type { PolicyOperatingSystem, UIPolicyConfig, @@ -75,6 +76,7 @@ const InnerEventsForm = ({ onValueSelection, supplementalOptions, }: EventsFormProps) => { + const { canWritePolicyManagement } = useUserPrivileges().endpointPrivileges; const policyDetailsConfig = usePolicyDetailsSelector(policyConfig); const theme = useContext(ThemeContext); const countSelected = useCallback(() => { @@ -122,6 +124,7 @@ const InnerEventsForm = ({ data-test-subj={`policy${OPERATING_SYSTEM_TO_TEST_SUBJ[os]}Event_${protectionField}`} checked={selection[protectionField]} onChange={(event) => onValueSelection(protectionField, event.target.checked)} + disabled={!canWritePolicyManagement} /> ); })} @@ -165,7 +168,10 @@ const InnerEventsForm = ({ data-test-subj={`policy${OPERATING_SYSTEM_TO_TEST_SUBJ[os]}Event_${protectionField}`} checked={selection[protectionField]} onChange={(event) => onValueSelection(protectionField, event.target.checked)} - disabled={isDisabled ? isDisabled(policyDetailsConfig) : false} + disabled={ + !canWritePolicyManagement || + (isDisabled ? isDisabled(policyDetailsConfig) : false) + } /> {tooltipText && ( diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/components/policy_endpoint_count.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/components/policy_endpoint_count.tsx index 7c0e3d470ec10..d5ba7d111d571 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/components/policy_endpoint_count.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/components/policy_endpoint_count.tsx @@ -16,10 +16,11 @@ import { getEndpointListPath, getPoliciesPath } from '../../../../common/routing import { APP_UI_ID } from '../../../../../../common/constants'; /** + * Returns a link component that navigates to the endpoint list page filtered by a specific policy + * * @param policyId * @param nonLinkCondition: boolean where the returned component is just text and not a link * - * Returns a link component that navigates to the endpoint list page filtered by a specific policy */ export const PolicyEndpointCount = memo< Omit & { diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_advanced.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_advanced.tsx index e6eab89e4f18f..44618a509d58b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_advanced.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_advanced.tsx @@ -21,6 +21,7 @@ import { import { cloneDeep } from 'lodash'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; +import { useUserPrivileges } from '../../../../common/components/user_privileges'; import { policyConfig } from '../store/policy_details/selectors'; import { usePolicyDetailsSelector } from './policy_hooks'; import { AdvancedPolicySchema } from '../models/advanced_policy_schema'; @@ -145,6 +146,7 @@ const PolicyAdvanced = React.memo( lastSupportedVersion?: string; documentation: string; }) => { + const { canWritePolicyManagement } = useUserPrivileges().endpointPrivileges; const dispatch = useDispatch(); const policyDetailsConfig = usePolicyDetailsSelector(policyConfig); const onChange = useCallback( @@ -196,6 +198,7 @@ const PolicyAdvanced = React.memo( fullWidth value={value as string} onChange={onChange} + disabled={!canWritePolicyManagement} /> diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/components/policy_form_layout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/components/policy_form_layout.test.tsx index 0ad249af74bc0..0f04c91d56394 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/components/policy_form_layout.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/components/policy_form_layout.test.tsx @@ -16,18 +16,23 @@ import { createAppRootMockRenderer, resetReactDomCreatePortalMock, } from '../../../../../../common/mock/endpoint'; -import { getPolicyDetailPath, getEndpointListPath } from '../../../../../common/routing'; +import { getPolicyDetailPath, getPoliciesPath } from '../../../../../common/routing'; import { policyListApiPathHandlers } from '../../../store/test_mock_utils'; import { licenseService } from '../../../../../../common/hooks/use_license'; import { PACKAGE_POLICY_API_ROOT, AGENT_API_ROUTES } from '@kbn/fleet-plugin/common'; +import { useUserPrivileges as _useUserPrivileges } from '../../../../../../common/components/user_privileges'; +import { getUserPrivilegesMockDefaultValue } from '../../../../../../common/components/user_privileges/__mocks__'; jest.mock('../../../../../../common/hooks/use_license'); +jest.mock('../../../../../../common/components/user_privileges'); + +const useUserPrivilegesMock = _useUserPrivileges as jest.Mock; describe('Policy Form Layout', () => { type FindReactWrapperResponse = ReturnType['find']>; const policyDetailsPathUrl = getPolicyDetailPath('1'); - const endpointListPath = getEndpointListPath({ name: 'endpointList' }); + const policyListPath = getPoliciesPath(); const sleep = (ms = 100) => new Promise((wakeup) => setTimeout(wakeup, ms)); const generator = new EndpointDocGenerator(); let history: AppContextTestRender['history']; @@ -123,7 +128,7 @@ describe('Policy Form Layout', () => { const navigateToAppMockedCalls = coreStart.application.navigateToApp.mock.calls; expect(navigateToAppMockedCalls[navigateToAppMockedCalls.length - 1]).toEqual([ 'securitySolutionUI', - { path: endpointListPath }, + { path: policyListPath }, ]); }); it('should display save button', async () => { @@ -142,6 +147,7 @@ describe('Policy Form Layout', () => { expect(saveButton).toHaveLength(1); expect(saveButton.text()).toEqual('beta'); }); + describe('when the save button is clicked', () => { let saveButton: FindReactWrapperResponse; let confirmModal: FindReactWrapperResponse; @@ -237,6 +243,7 @@ describe('Policy Form Layout', () => { }); }); }); + describe('when the subscription tier is platinum or higher', () => { beforeEach(() => { (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(true); @@ -288,6 +295,7 @@ describe('Policy Form Layout', () => { expect(ransomware).toHaveLength(1); }); }); + describe('when the subscription tier is gold or lower', () => { beforeEach(() => { (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false); @@ -330,5 +338,40 @@ describe('Policy Form Layout', () => { expect(lockedCard).toHaveLength(4); }); }); + + describe('and user has only READ privilege', () => { + beforeEach(() => { + const mockedPrivileges = getUserPrivilegesMockDefaultValue(); + mockedPrivileges.endpointPrivileges.canWritePolicyManagement = false; + + useUserPrivilegesMock.mockReturnValue(mockedPrivileges); + + policyFormLayoutView = render(); + }); + + afterEach(() => { + useUserPrivilegesMock.mockImplementation(getUserPrivilegesMockDefaultValue); + }); + + it('should not display the Save button', () => { + expect( + policyFormLayoutView.find('EuiButton[data-test-subj="policyDetailsSaveButton"]') + ).toHaveLength(0); + }); + + it('should display all form controls as disabled', () => { + policyFormLayoutView + .find('button[data-test-subj="advancedPolicyButton"]') + .simulate('click'); + + const inputElements = policyFormLayoutView.find('input'); + + expect(inputElements.length).toBeGreaterThan(0); + + inputElements.forEach((element) => { + expect(element.prop('disabled')).toBe(true); + }); + }); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/components/policy_form_layout.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/components/policy_form_layout.tsx index e2a77dd5caa2e..ad9246b01a699 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/components/policy_form_layout.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/components/policy_form_layout.tsx @@ -22,6 +22,8 @@ import { useDispatch } from 'react-redux'; import { useLocation } from 'react-router-dom'; import type { ApplicationStart } from '@kbn/core/public'; import { toMountPoint } from '@kbn/kibana-react-plugin/public'; +import { useIsExperimentalFeatureEnabled } from '../../../../../../common/hooks/use_experimental_features'; +import { useUserPrivileges } from '../../../../../../common/components/user_privileges'; import { usePolicyDetailsSelector } from '../../policy_hooks'; import { policyDetails, @@ -32,7 +34,7 @@ import { import { useToasts, useKibana } from '../../../../../../common/lib/kibana'; import type { AppAction } from '../../../../../../common/store/actions'; -import { getEndpointListPath } from '../../../../../common/routing'; +import { getEndpointListPath, getPoliciesPath } from '../../../../../common/routing'; import { useNavigateToAppEventHandler } from '../../../../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; import { APP_UI_ID } from '../../../../../../../common/constants'; import type { PolicyDetailsRouteState } from '../../../../../../../common/endpoint/types'; @@ -50,6 +52,7 @@ export const PolicyFormLayout = React.memo(() => { } = useKibana(); const toasts = useToasts(); const { state: locationRouteState } = useLocation(); + const { canWritePolicyManagement } = useUserPrivileges().endpointPrivileges; // Store values const policyItem = usePolicyDetailsSelector(policyDetails); @@ -61,12 +64,23 @@ export const PolicyFormLayout = React.memo(() => { const [showConfirm, setShowConfirm] = useState(false); const [routeState, setRouteState] = useState(); const policyName = policyItem?.name ?? ''; - const hostListRouterPath = getEndpointListPath({ name: 'endpointList' }); + const isPolicyListEnabled = useIsExperimentalFeatureEnabled('policyListEnabled'); const routingOnCancelNavigateTo = routeState?.onCancelNavigateTo; const navigateToAppArguments = useMemo((): Parameters => { - return routingOnCancelNavigateTo ?? [APP_UI_ID, { path: hostListRouterPath }]; - }, [hostListRouterPath, routingOnCancelNavigateTo]); + if (routingOnCancelNavigateTo) { + return routingOnCancelNavigateTo; + } + + return [ + APP_UI_ID, + { + path: isPolicyListEnabled + ? getPoliciesPath() + : getEndpointListPath({ name: 'endpointList' }), + }, + ]; + }, [isPolicyListEnabled, routingOnCancelNavigateTo]); // Handle showing update statuses useEffect(() => { @@ -167,20 +181,22 @@ export const PolicyFormLayout = React.memo(() => { - - - - - + {canWritePolicyManagement && ( + + + + + + )} diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/components/protection_radio.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/components/protection_radio.tsx index b875f755b258e..8a1ff4204e23a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/components/protection_radio.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/components/protection_radio.tsx @@ -9,6 +9,7 @@ import React, { useCallback, useMemo } from 'react'; import { useDispatch } from 'react-redux'; import { cloneDeep } from 'lodash'; import { htmlIdGenerator, EuiRadio } from '@elastic/eui'; +import { useUserPrivileges } from '../../../../../../common/components/user_privileges'; import type { ImmutableArray, UIPolicyConfig } from '../../../../../../../common/endpoint/types'; import { ProtectionModes } from '../../../../../../../common/endpoint/types'; import type { MacPolicyProtection, LinuxPolicyProtection, PolicyProtection } from '../../../types'; @@ -34,6 +35,7 @@ export const ProtectionRadio = React.memo( const radioButtonId = useMemo(() => htmlIdGenerator()(), []); const selected = policyDetailsConfig && policyDetailsConfig.windows[protection].mode; const isPlatinumPlus = useLicense().isPlatinumPlus(); + const { canWritePolicyManagement } = useUserPrivileges().endpointPrivileges; const handleRadioChange = useCallback(() => { if (policyDetailsConfig) { @@ -87,7 +89,7 @@ export const ProtectionRadio = React.memo( id={radioButtonId} checked={selected === protectionMode} onChange={handleRadioChange} - disabled={selected === ProtectionModes.off} + disabled={!canWritePolicyManagement || selected === ProtectionModes.off} /> ); } diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/components/protection_switch.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/components/protection_switch.tsx index 75d79439a14fc..a564c2b2f83cb 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/components/protection_switch.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/components/protection_switch.tsx @@ -10,6 +10,7 @@ import { useDispatch } from 'react-redux'; import { i18n } from '@kbn/i18n'; import { EuiSwitch } from '@elastic/eui'; import { cloneDeep } from 'lodash'; +import { useUserPrivileges } from '../../../../../../common/components/user_privileges'; import { useLicense } from '../../../../../../common/hooks/use_license'; import { policyConfig } from '../../../store/policy_details/selectors'; import { usePolicyDetailsSelector } from '../../policy_hooks'; @@ -40,6 +41,7 @@ export const ProtectionSwitch = React.memo( }) => { const policyDetailsConfig = usePolicyDetailsSelector(policyConfig); const isPlatinumPlus = useLicense().isPlatinumPlus(); + const { canWritePolicyManagement } = useUserPrivileges().endpointPrivileges; const dispatch = useDispatch<(action: AppAction) => void>(); const selected = policyDetailsConfig && policyDetailsConfig.windows[protection].mode; @@ -123,6 +125,7 @@ export const ProtectionSwitch = React.memo( })} checked={selected !== ProtectionModes.off} onChange={handleSwitchChange} + disabled={!canWritePolicyManagement} /> ); } diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/components/user_notification.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/components/user_notification.tsx index 2a9dd8bffb6d9..f08ec74c95b5a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/components/user_notification.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/components/user_notification.tsx @@ -19,6 +19,7 @@ import { EuiText, EuiTextArea, } from '@elastic/eui'; +import { useUserPrivileges } from '../../../../../../common/components/user_privileges'; import type { ImmutableArray, UIPolicyConfig } from '../../../../../../../common/endpoint/types'; import { ProtectionModes } from '../../../../../../../common/endpoint/types'; import type { PolicyProtection, MacPolicyProtection, LinuxPolicyProtection } from '../../../types'; @@ -36,6 +37,7 @@ export const UserNotification = React.memo( protection: PolicyProtection; osList: ImmutableArray>; }) => { + const { canWritePolicyManagement } = useUserPrivileges().endpointPrivileges; const policyDetailsConfig = usePolicyDetailsSelector(policyConfig); const dispatch = useDispatch<(action: AppAction) => void>(); const selected = policyDetailsConfig && policyDetailsConfig.windows[protection].mode; @@ -139,7 +141,7 @@ export const UserNotification = React.memo( id={`${protection}UserNotificationCheckbox}`} onChange={handleUserNotificationCheckbox} checked={userNotificationSelected} - disabled={selected === ProtectionModes.off} + disabled={!canWritePolicyManagement || selected === ProtectionModes.off} label={i18n.translate('xpack.securitySolution.endpoint.policyDetail.notifyUser', { defaultMessage: 'Notify user', })} @@ -196,6 +198,7 @@ export const UserNotification = React.memo( value={userNotificationMessage} onChange={handleCustomUserNotification} fullWidth={true} + disabled={!canWritePolicyManagement} data-test-subj={`${protection}UserNotificationCustomMessage`} /> diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx index 00d4ddf2197c6..e6b69f21d26f3 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx @@ -19,6 +19,7 @@ import { import { OperatingSystem } from '@kbn/securitysolution-utils'; import { useDispatch } from 'react-redux'; import { cloneDeep } from 'lodash'; +import { useUserPrivileges } from '../../../../../../common/components/user_privileges'; import { APP_UI_ID } from '../../../../../../../common/constants'; import { SecurityPageName } from '../../../../../../app/types'; import type { @@ -59,6 +60,7 @@ export const MalwareProtections = React.memo(() => { defaultMessage: 'Blocklist enabled', } ); + const { canWritePolicyManagement } = useUserPrivileges().endpointPrivileges; const isPlatinumPlus = useLicense().isPlatinumPlus(); const dispatch = useDispatch<(action: AppAction) => void>(); const policyDetailsConfig = usePolicyDetailsSelector(policyConfig); @@ -117,7 +119,9 @@ export const MalwareProtections = React.memo(() => { label={blocklistLabel} checked={policyDetailsConfig.windows[protection].blocklist} onChange={handleBlocklistSwitchChange} - disabled={policyDetailsConfig.windows[protection].mode === 'off'} + disabled={ + !canWritePolicyManagement || policyDetailsConfig.windows[protection].mode === 'off' + } /> diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx index 33fd25d0d15cf..ff2c194d435b4 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx @@ -21,6 +21,7 @@ import { i18n } from '@kbn/i18n'; import { useLocation } from 'react-router-dom'; import type { CreatePackagePolicyRouteState } from '@kbn/fleet-plugin/public'; import { pagePathGetters } from '@kbn/fleet-plugin/public'; +import { useUserPrivileges } from '../../../../common/components/user_privileges'; import { AdministrationListPage } from '../../../components/administration_list_page'; import { FormattedDate } from '../../../../common/components/formatted_date'; import { EndpointPolicyLink } from '../../../components/endpoint_policy_link'; @@ -40,6 +41,7 @@ import { PolicyEndpointCount } from './components/policy_endpoint_count'; import { ManagementEmptyStateWrapper } from '../../../components/management_empty_state_wrapper'; export const PolicyList = memo(() => { + const { canReadEndpointList, loading: authLoading } = useUserPrivileges().endpointPrivileges; const { pagination, pageSizeOptions, setPagination } = useUrlPagination(); const { search } = useLocation(); const { getAppUrl } = useAppUrl(); @@ -266,7 +268,7 @@ export const PolicyList = memo(() => { className="eui-textTruncate" data-test-subj="policyEndpointCountLink" policyId={policy.id} - nonLinkCondition={count === 0} + nonLinkCondition={authLoading || !canReadEndpointList || count === 0} > {count} @@ -274,7 +276,7 @@ export const PolicyList = memo(() => { }, }, ]; - }, [policyIdToEndpointCount, backLink]); + }, [backLink, policyIdToEndpointCount, authLoading, canReadEndpointList]); const handleTableOnChange = useCallback( ({ page }: CriteriaWithPagination) => {