diff --git a/x-pack/plugins/fleet/common/authz.test.ts b/x-pack/plugins/fleet/common/authz.test.ts index ced783c6d4ad..cadb90651b01 100644 --- a/x-pack/plugins/fleet/common/authz.test.ts +++ b/x-pack/plugins/fleet/common/authz.test.ts @@ -15,7 +15,10 @@ import { ENDPOINT_PRIVILEGES } from './constants'; const SECURITY_SOLUTION_ID = DEFAULT_APP_CATEGORIES.security.id; -function generateActions(privileges: string[] = [], overrides: Record = {}) { +function generateActions( + privileges: typeof ENDPOINT_PRIVILEGES, + overrides: Record = {} +) { return privileges.reduce((acc, privilege) => { const executePackageAction = overrides[privilege] || false; diff --git a/x-pack/plugins/fleet/common/constants/authz.ts b/x-pack/plugins/fleet/common/constants/authz.ts index 3bf1aeb46adc..d7a0ca4ade2e 100644 --- a/x-pack/plugins/fleet/common/constants/authz.ts +++ b/x-pack/plugins/fleet/common/constants/authz.ts @@ -23,4 +23,4 @@ export const ENDPOINT_PRIVILEGES = [ 'writeHostIsolation', 'writeProcessOperations', 'writeFileOperations', -]; +] as const; diff --git a/x-pack/plugins/fleet/common/mocks.ts b/x-pack/plugins/fleet/common/mocks.ts index bc8880ed385a..d5aca8398abd 100644 --- a/x-pack/plugins/fleet/common/mocks.ts +++ b/x-pack/plugins/fleet/common/mocks.ts @@ -7,6 +7,7 @@ import type { DeletePackagePoliciesResponse, NewPackagePolicy, PackagePolicy } from './types'; import type { FleetAuthz } from './authz'; +import { ENDPOINT_PRIVILEGES } from './constants'; export const createNewPackagePolicyMock = (): NewPackagePolicy => { return { @@ -61,6 +62,15 @@ export const deletePackagePolicyMock = (): DeletePackagePoliciesResponse => { * Creates mock `authz` object */ export const createFleetAuthzMock = (): FleetAuthz => { + const endpointActions = ENDPOINT_PRIVILEGES.reduce((acc, privilege) => { + return { + ...acc, + [privilege]: { + executePackageAction: true, + }, + }; + }, {}); + return { fleet: { all: true, @@ -80,5 +90,10 @@ export const createFleetAuthzMock = (): FleetAuthz => { readIntegrationPolicies: true, writeIntegrationPolicies: true, }, + packagePrivileges: { + endpoint: { + actions: endpointActions, + }, + }, }; }; diff --git a/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.test.ts b/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.test.ts index 3434e95f29b4..b9c0dcff2054 100644 --- a/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.test.ts @@ -107,6 +107,40 @@ describe('Endpoint Authz service', () => { ); }); }); + + describe('endpoint rbac is enabled', () => { + describe('canIsolateHost', () => { + it('should be true if packagePrivilege.writeHostIsolation is true', () => { + fleetAuthz.packagePrivileges!.endpoint.actions.writeHostIsolation.executePackageAction = + true; + const authz = calculateEndpointAuthz(licenseService, fleetAuthz, userRoles, true); + expect(authz.canIsolateHost).toBe(true); + }); + + it('should be false if packagePrivilege.writeHostIsolation is false', () => { + fleetAuthz.packagePrivileges!.endpoint.actions.writeHostIsolation.executePackageAction = + false; + const authz = calculateEndpointAuthz(licenseService, fleetAuthz, userRoles, true); + expect(authz.canIsolateHost).toBe(false); + }); + }); + + describe('canUnIsolateHost', () => { + it('should be true if packagePrivilege.writeHostIsolation is true', () => { + fleetAuthz.packagePrivileges!.endpoint.actions.writeHostIsolation.executePackageAction = + true; + const authz = calculateEndpointAuthz(licenseService, fleetAuthz, userRoles, true); + expect(authz.canUnIsolateHost).toBe(true); + }); + + it('should be false if packagePrivilege.writeHostIsolation is false', () => { + fleetAuthz.packagePrivileges!.endpoint.actions.writeHostIsolation.executePackageAction = + false; + const authz = calculateEndpointAuthz(licenseService, fleetAuthz, userRoles, true); + expect(authz.canUnIsolateHost).toBe(false); + }); + }); + }); }); describe('getEndpointAuthzInitialState()', () => { 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 6f578ec24d85..bc40bd11f5d7 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 @@ -28,14 +28,18 @@ export const calculateEndpointAuthz = ( const isPlatinumPlusLicense = licenseService.isPlatinumPlus(); const isEnterpriseLicense = licenseService.isEnterprise(); const hasEndpointManagementAccess = userRoles.includes('superuser'); + const canIsolateHost = isEndpointRbacEnabled + ? fleetAuthz.packagePrivileges?.endpoint?.actions?.writeHostIsolation?.executePackageAction || + false + : hasEndpointManagementAccess; return { canAccessFleet: fleetAuthz?.fleet.all ?? userRoles.includes('superuser'), canAccessEndpointManagement: hasEndpointManagementAccess, canCreateArtifactsByPolicy: hasEndpointManagementAccess && isPlatinumPlusLicense, // Response Actions - canIsolateHost: isPlatinumPlusLicense && hasEndpointManagementAccess, - canUnIsolateHost: hasEndpointManagementAccess, + canIsolateHost: isPlatinumPlusLicense && canIsolateHost, + canUnIsolateHost: canIsolateHost, canKillProcess: hasEndpointManagementAccess && isEnterpriseLicense, canSuspendProcess: hasEndpointManagementAccess && isEnterpriseLicense, canGetRunningProcesses: hasEndpointManagementAccess && isEnterpriseLicense, diff --git a/x-pack/plugins/security_solution/public/common/components/user_privileges/endpoint/use_endpoint_privileges.test.ts b/x-pack/plugins/security_solution/public/common/components/user_privileges/endpoint/use_endpoint_privileges.test.ts index 29da5688357b..a0d23820025b 100644 --- a/x-pack/plugins/security_solution/public/common/components/user_privileges/endpoint/use_endpoint_privileges.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/user_privileges/endpoint/use_endpoint_privileges.test.ts @@ -7,15 +7,19 @@ import type { RenderHookResult, RenderResult } from '@testing-library/react-hooks'; import { act, renderHook } from '@testing-library/react-hooks'; -import { useCurrentUser, useKibana } from '../../../lib/kibana'; -import { useEndpointPrivileges } from './use_endpoint_privileges'; + import { securityMock } from '@kbn/security-plugin/public/mocks'; import type { AuthenticatedUser } from '@kbn/security-plugin/common'; -import { licenseService } from '../../../hooks/use_license'; -import { getEndpointPrivilegesInitialStateMock } from './mocks'; +import { createFleetAuthzMock } from '@kbn/fleet-plugin/common'; + import type { EndpointPrivileges } from '../../../../../common/endpoint/types'; +import { useCurrentUser, useKibana } from '../../../lib/kibana'; +import { licenseService } from '../../../hooks/use_license'; +import { useEndpointPrivileges } from './use_endpoint_privileges'; +import { getEndpointPrivilegesInitialStateMock } from './mocks'; import { getEndpointPrivilegesInitialState } from './utils'; +const useKibanaMock = useKibana as jest.Mocked; jest.mock('../../../lib/kibana'); jest.mock('../../../hooks/use_license', () => { const licenseServiceInstance = { @@ -47,6 +51,7 @@ describe('When using useEndpointPrivileges hook', () => { }); (useCurrentUser as jest.Mock).mockReturnValue(authenticatedUser); + useKibanaMock().services.fleet!.authz = createFleetAuthzMock(); licenseServiceMock.isPlatinumPlus.mockReturnValue(true); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/actions_menu.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/actions_menu.test.tsx index 6fc8a99ee732..35fe96d94d91 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/actions_menu.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/actions_menu.test.tsx @@ -14,6 +14,9 @@ import { act } from '@testing-library/react'; import { endpointPageHttpMock } from '../../../mocks'; import { fireEvent } from '@testing-library/dom'; import { licenseService } from '../../../../../../common/hooks/use_license'; +import { useUserPrivileges } from '../../../../../../common/components/user_privileges'; +import { initialUserPrivilegesState } from '../../../../../../common/components/user_privileges/user_privileges_context'; +import { getUserPrivilegesMockDefaultValue } from '../../../../../../common/components/user_privileges/__mocks__'; jest.mock('../../../../../../common/lib/kibana/kibana_react', () => { const originalModule = jest.requireActual('../../../../../../common/lib/kibana/kibana_react'); @@ -31,6 +34,7 @@ jest.mock('../../../../../../common/lib/kibana/kibana_react', () => { }; }); jest.mock('../../../../../../common/hooks/use_license'); +jest.mock('../../../../../../common/components/user_privileges'); describe('When using the Endpoint Details Actions Menu', () => { let render: () => Promise>; @@ -59,6 +63,8 @@ describe('When using the Endpoint Details Actions Menu', () => { waitForAction = mockedContext.middlewareSpy.waitForAction; httpMocks = endpointPageHttpMock(mockedContext.coreStart.http); + (useUserPrivileges as jest.Mock).mockReturnValue(getUserPrivilegesMockDefaultValue()); + act(() => { mockedContext.history.push( '/administration/endpoints?selected_endpoint=5fe11314-678c-413e-87a2-b4a3461878ee' @@ -80,6 +86,10 @@ describe('When using the Endpoint Details Actions Menu', () => { }; }); + afterEach(() => { + (useUserPrivileges as jest.Mock).mockClear(); + }); + it('should not show the response actions history link', async () => { await render(); expect(renderResult.queryByTestId('actionsLink')).toBeNull(); @@ -121,18 +131,38 @@ describe('When using the Endpoint Details Actions Menu', () => { describe('and endpoint host is isolated', () => { beforeEach(() => setEndpointMetadataResponse(true)); - it('should display Unisolate action', async () => { - await render(); - expect(renderResult.getByTestId('unIsolateLink')).not.toBeNull(); - }); - - it('should navigate via router when unisolate is clicked', async () => { - await render(); - act(() => { - fireEvent.click(renderResult.getByTestId('unIsolateLink')); + describe('and user has unisolate privilege', () => { + it('should display Unisolate action', async () => { + await render(); + expect(renderResult.getByTestId('unIsolateLink')).not.toBeNull(); }); - expect(coreStart.application.navigateToApp).toHaveBeenCalled(); + it('should navigate via router when unisolate is clicked', async () => { + await render(); + act(() => { + fireEvent.click(renderResult.getByTestId('unIsolateLink')); + }); + + expect(coreStart.application.navigateToApp).toHaveBeenCalled(); + }); + }); + + describe('and user does not have unisolate privilege', () => { + beforeEach(() => { + (useUserPrivileges as jest.Mock).mockReturnValue({ + ...initialUserPrivilegesState(), + endpointPrivileges: { + ...initialUserPrivilegesState().endpointPrivileges, + canIsolateHost: false, + canUnIsolateHost: false, + }, + }); + }); + + it('should not display unisolate action', async () => { + await render(); + expect(renderResult.queryByTestId('unIsolateLink')).toBeNull(); + }); }); }); @@ -143,12 +173,6 @@ describe('When using the Endpoint Details Actions Menu', () => { afterEach(() => licenseServiceMock.isPlatinumPlus.mockReturnValue(true)); - it('should not show the `isolate` action', async () => { - setEndpointMetadataResponse(); - await render(); - expect(renderResult.queryByTestId('isolateLink')).toBeNull(); - }); - it('should still show `unisolate` action for endpoints that are currently isolated', async () => { setEndpointMetadataResponse(true); await render(); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/use_endpoint_action_items.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/use_endpoint_action_items.tsx index 40fd81c4ab58..9a9c884a3979 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/use_endpoint_action_items.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/use_endpoint_action_items.tsx @@ -19,7 +19,6 @@ import { agentPolicies, uiQueryParams } from '../../store/selectors'; import { useAppUrl } from '../../../../../common/lib/kibana/hooks'; import type { ContextMenuItemNavByRouterProps } from '../../../../components/context_menu_with_router_support/context_menu_item_nav_by_router'; import { isEndpointHostIsolated } from '../../../../../common/utils/validators'; -import { useLicense } from '../../../../../common/hooks/use_license'; import { isIsolationSupported } from '../../../../../../common/endpoint/service/host_isolation/utils'; import { useDoesEndpointSupportResponder } from '../../../../../common/hooks/endpoint/use_does_endpoint_support_responder'; import { UPGRADE_ENDPOINT_FOR_RESPONDER } from '../../../../../common/translations'; @@ -36,7 +35,6 @@ export const useEndpointActionItems = ( endpointMetadata: MaybeImmutable | undefined, options?: Options ): ContextMenuItemNavByRouterProps[] => { - const isPlatinumPlus = useLicense().isPlatinumPlus(); const { getAppUrl } = useAppUrl(); const fleetAgentPolicies = useEndpointSelector(agentPolicies); const allCurrentUrlParams = useEndpointSelector(uiQueryParams); @@ -44,7 +42,8 @@ export const useEndpointActionItems = ( const isResponseActionsConsoleEnabled = useIsExperimentalFeatureEnabled( 'responseActionsConsoleEnabled' ); - const canAccessResponseConsole = useUserPrivileges().endpointPrivileges.canAccessResponseConsole; + const { canAccessResponseConsole, canIsolateHost, canUnIsolateHost } = + useUserPrivileges().endpointPrivileges; const isResponderCapabilitiesEnabled = useDoesEndpointSupportResponder(endpointMetadata); return useMemo(() => { @@ -82,8 +81,8 @@ export const useEndpointActionItems = ( const isolationActions = []; - if (isIsolated) { - // Un-isolate is always available to users regardless of license level + if (isIsolated && canUnIsolateHost) { + // Un-isolate is available to users regardless of license level if they have unisolate permissions isolationActions.push({ 'data-test-subj': 'unIsolateLink', icon: 'lockOpen', @@ -100,7 +99,7 @@ export const useEndpointActionItems = ( /> ), }); - } else if (isPlatinumPlus && isolationSupported) { + } else if (isolationSupported && canIsolateHost) { // For Platinum++ licenses, users also have ability to isolate isolationActions.push({ 'data-test-subj': 'isolateLink', @@ -260,10 +259,11 @@ export const useEndpointActionItems = ( endpointMetadata, fleetAgentPolicies, getAppUrl, - isPlatinumPlus, isResponseActionsConsoleEnabled, showEndpointResponseActionsConsole, options?.isEndpointList, isResponderCapabilitiesEnabled, + canIsolateHost, + canUnIsolateHost, ]); }; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx index b85ad2cc7f6a..7d8d625d97e3 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx @@ -1007,6 +1007,7 @@ describe('when on the endpoint list page', () => { let agentId: string; let agentPolicyId: string; let renderResult: ReturnType; + let endpointActionsButton: HTMLElement; // 2nd endpoint only has isolation capabilities const mockEndpointListApi = () => { @@ -1081,13 +1082,7 @@ describe('when on the endpoint list page', () => { beforeEach(async () => { mockEndpointListApi(); - (useUserPrivileges as jest.Mock).mockReturnValue({ - ...mockInitialUserPrivilegesState(), - endpointPrivileges: { - ...mockInitialUserPrivilegesState().endpointPrivileges, - canAccessResponseConsole: true, - }, - }); + (useUserPrivileges as jest.Mock).mockReturnValue(getUserPrivilegesMockDefaultValue()); reactTestingLibrary.act(() => { history.push(`${MANAGEMENT_PATH}/endpoints`); @@ -1097,9 +1092,7 @@ describe('when on the endpoint list page', () => { await middlewareSpy.waitForAction('serverReturnedEndpointList'); await middlewareSpy.waitForAction('serverReturnedEndpointAgentPolicies'); - const endpointActionsButton = ( - await renderResult.findAllByTestId('endpointTableRowActions') - )[0]; + endpointActionsButton = (await renderResult.findAllByTestId('endpointTableRowActions'))[0]; reactTestingLibrary.act(() => { reactTestingLibrary.fireEvent.click(endpointActionsButton); @@ -1108,7 +1101,6 @@ describe('when on the endpoint list page', () => { afterEach(() => { jest.clearAllMocks(); - (useUserPrivileges as jest.Mock).mockReturnValue(getUserPrivilegesMockDefaultValue()); }); it('shows the Responder option when all 3 processes capabilities are present in the endpoint', async () => { @@ -1141,6 +1133,24 @@ describe('when on the endpoint list page', () => { ); }); + it('hides isolate host option if canIsolateHost is false', () => { + (useUserPrivileges as jest.Mock).mockReturnValue({ + ...mockInitialUserPrivilegesState(), + endpointPrivileges: { + ...mockInitialUserPrivilegesState().endpointPrivileges, + canIsolateHost: false, + }, + }); + reactTestingLibrary.act(() => { + reactTestingLibrary.fireEvent.click(endpointActionsButton); + }); + reactTestingLibrary.act(() => { + reactTestingLibrary.fireEvent.click(endpointActionsButton); + }); + const isolateLink = screen.queryByTestId('isolateLink'); + expect(isolateLink).toBeNull(); + }); + it('navigates to the Security Solution Host Details page', async () => { const hostLink = await renderResult.findByTestId('hostLink'); expect(hostLink.getAttribute('href')).toEqual(