[Security Solution][RBAC][Endpoint List] RBAC permission guards reflect correct UI for endpoint list onboarding screen (#144958)

## Summary
- Removes endpoint list link from Manage Navigation if user does not
have at least endpoint list READ privileges
- Shows a No Privileges page if user 1. has no endpoint list access
but has fleet access, or 2. has neither endpoint list access nor fleet
access
- Shows a modified onboarding screen if the user has endpoint list
access but no fleet access
- General endpoint list onboarding flow is shown otherwise
- [x] Unit tests for links and for endpoint list access
This commit is contained in:
Candace Park 2022-11-21 19:43:16 -05:00 committed by GitHub
parent 5e925f7860
commit 8e684bea77
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 254 additions and 98 deletions

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import type { MouseEvent, CSSProperties } from 'react';
import type { MouseEvent, CSSProperties, ReactNode } from 'react';
import React, { useMemo } from 'react';
import type { EuiSelectableProps } from '@elastic/eui';
import {
@ -43,90 +43,107 @@ interface ManagementStep {
const PolicyEmptyState = React.memo<{
loading: boolean;
onActionClick: (event: MouseEvent<HTMLAnchorElement | HTMLButtonElement>) => void;
onActionClick?: (event: MouseEvent<HTMLAnchorElement | HTMLButtonElement>) => void;
actionDisabled?: boolean;
actionHidden?: boolean;
additionalInfo?: ReactNode;
policyEntryPoint?: boolean;
}>(({ loading, onActionClick, actionDisabled, policyEntryPoint = false }) => {
const docLinks = useKibana().services.docLinks;
return (
<div data-test-subj="emptyPolicyTable">
{loading ? (
<EuiFlexGroup alignItems="center" justifyContent="center">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="xl" className="essentialAnimation" />
</EuiFlexItem>
</EuiFlexGroup>
) : (
<EuiFlexGroup data-test-subj="policyOnboardingInstructions" alignItems="center">
<EuiFlexItem grow={1}>
<EuiText>
<h1>
<FormattedMessage
id="xpack.securitySolution.endpoint.policyList.onboardingTitle"
defaultMessage="Get started with Elastic Defend"
/>
</h1>
</EuiText>
<EuiSpacer size="m" />
<EuiText size="s" color="subdued">
<FormattedMessage
id="xpack.securitySolution.endpoint.policyList.onboardingSectionOne"
defaultMessage="Protect your hosts with threat prevention, detection, and deep security data visibility."
/>
</EuiText>
<EuiSpacer size="m" />
<EuiText size="s" color="subdued">
{policyEntryPoint ? (
<FormattedMessage
id="xpack.securitySolution.endpoint.policyList.onboardingSectionTwo.fromPolicyPage"
defaultMessage="From this page, youll be able to view and manage the Elastic Defend Integration policies in your environment running Elastic Defend."
/>
) : (
<FormattedMessage
id="xpack.securitySolution.endpoint.policyList.onboardingSectionTwo.fromEndpointPage"
defaultMessage="From this page, youll be able to view and manage the hosts in your environment running Elastic Defend."
/>
)}
</EuiText>
<EuiSpacer size="m" />
<EuiText size="s" color="subdued">
<FormattedMessage
id="xpack.securitySolution.endpoint.policyList.onboardingSectionThree"
defaultMessage="To get started, add the Elastic Defend integration to your Agents. For more information, "
/>
<EuiLink external href={`${docLinks.links.siem.guide}`}>
<FormattedMessage
id="xpack.securitySolution.endpoint.policyList.onboardingDocsLink"
defaultMessage="view the Elastic Security documentation"
/>
</EuiLink>
</EuiText>
<EuiSpacer size="l" />
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButton
fill
iconType="plusInCircle"
onClick={onActionClick}
isDisabled={actionDisabled}
data-test-subj="onboardingStartButton"
>
}>(
({
loading,
onActionClick,
actionDisabled,
actionHidden,
additionalInfo,
policyEntryPoint = false,
}) => {
const docLinks = useKibana().services.docLinks;
return (
<div data-test-subj="emptyPolicyTable">
{loading ? (
<EuiFlexGroup alignItems="center" justifyContent="center">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="xl" className="essentialAnimation" />
</EuiFlexItem>
</EuiFlexGroup>
) : (
<EuiFlexGroup data-test-subj="policyOnboardingInstructions" alignItems="center">
<EuiFlexItem grow={1}>
<EuiText>
<h1>
<FormattedMessage
id="xpack.securitySolution.endpoint.policyList.actionButtonText"
defaultMessage="Add Elastic Defend"
id="xpack.securitySolution.endpoint.policyList.onboardingTitle"
defaultMessage="Get started with Elastic Defend"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={2}>
<EuiIcon type={onboardingLogo} size="original" style={MAX_SIZE_ONBOARDING_LOGO} />
</EuiFlexItem>
</EuiFlexGroup>
)}
</div>
);
});
</h1>
</EuiText>
<EuiSpacer size="m" />
<EuiText size="s" color="subdued">
<FormattedMessage
id="xpack.securitySolution.endpoint.policyList.onboardingSectionOne"
defaultMessage="Protect your hosts with threat prevention, detection, and deep security data visibility."
/>
</EuiText>
<EuiSpacer size="m" />
<EuiText size="s" color="subdued">
{policyEntryPoint ? (
<FormattedMessage
id="xpack.securitySolution.endpoint.policyList.onboardingSectionTwo.fromPolicyPage"
defaultMessage="From this page, youll be able to view and manage the Elastic Defend Integration policies in your environment running Elastic Defend."
/>
) : (
<FormattedMessage
id="xpack.securitySolution.endpoint.policyList.onboardingSectionTwo.fromEndpointPage"
defaultMessage="From this page, youll be able to view and manage the hosts in your environment running Elastic Defend."
/>
)}
</EuiText>
<EuiSpacer size="m" />
<EuiText size="s" color="subdued">
<FormattedMessage
id="xpack.securitySolution.endpoint.policyList.onboardingSectionThree"
defaultMessage="To get started, add the Elastic Defend integration to your Agents. For more information, "
/>
<EuiLink external href={`${docLinks.links.siem.guide}`}>
<FormattedMessage
id="xpack.securitySolution.endpoint.policyList.onboardingDocsLink"
defaultMessage="view the Elastic Security documentation"
/>
</EuiLink>
</EuiText>
<EuiSpacer size="m" />
{additionalInfo}
{!actionHidden && (
<>
<EuiSpacer size="s" />
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButton
fill
iconType="plusInCircle"
onClick={onActionClick}
isDisabled={actionDisabled}
data-test-subj="onboardingStartButton"
>
<FormattedMessage
id="xpack.securitySolution.endpoint.policyList.actionButtonText"
defaultMessage="Add Elastic Defend"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</>
)}
</EuiFlexItem>
<EuiFlexItem grow={2}>
<EuiIcon type={onboardingLogo} size="original" style={MAX_SIZE_ONBOARDING_LOGO} />
</EuiFlexItem>
</EuiFlexGroup>
)}
</div>
);
}
);
const EndpointsEmptyState = React.memo<{
loading: boolean;

View file

@ -96,6 +96,7 @@ describe('links', () => {
canUnIsolateHost: false,
canAccessEndpointManagement: true,
canReadActionsLogManagement: true,
canReadEndpointList: true,
});
const filteredLinks = await getManagementFilteredLinks(
@ -114,6 +115,7 @@ describe('links', () => {
canUnIsolateHost: true,
canAccessEndpointManagement: true,
canReadActionsLogManagement: true,
canReadEndpointList: true,
});
fakeHttpServices.get.mockResolvedValue({ total: 0 });
@ -133,6 +135,7 @@ describe('links', () => {
canUnIsolateHost: true,
canAccessEndpointManagement: false,
canReadActionsLogManagement: true,
canReadEndpointList: true,
});
fakeHttpServices.get.mockResolvedValue({ total: 1 });
@ -166,6 +169,7 @@ describe('links', () => {
canIsolateHost: false,
canUnIsolateHost: true,
canReadActionsLogManagement: true,
canReadEndpointList: true,
});
fakeHttpServices.get.mockRejectedValue(new Error());
@ -184,6 +188,7 @@ describe('links', () => {
canIsolateHost: false,
canUnIsolateHost: true,
canReadActionsLogManagement: false,
canReadEndpointList: true,
});
fakeHttpServices.get.mockRejectedValue(new Error());
@ -201,4 +206,21 @@ describe('links', () => {
});
});
});
describe('Endpoint List', () => {
it('should return all but endpoints link when no Endpoint List READ access', async () => {
(calculateEndpointAuthz as jest.Mock).mockReturnValue(
getEndpointAuthzInitialStateMock({
canReadEndpointList: false,
})
);
const filteredLinks = await getManagementFilteredLinks(
coreMockStarted,
getPlugins(['superuser'])
);
expect(filteredLinks).toEqual({
...links,
links: links.links?.filter((link) => link.id !== SecurityPageName.endpoints),
});
});
});
});

View file

@ -273,16 +273,21 @@ export const getManagementFilteredLinks = async (
);
}
const { canReadActionsLogManagement, canReadHostIsolationExceptions } = fleetAuthz
? calculateEndpointAuthz(
licenseService,
fleetAuthz,
currentUser.roles,
isEndpointRbacEnabled,
endpointPermissions,
hasHostIsolationExceptions
)
: getEndpointAuthzInitialState();
const { canReadActionsLogManagement, canReadHostIsolationExceptions, canReadEndpointList } =
fleetAuthz
? calculateEndpointAuthz(
licenseService,
fleetAuthz,
currentUser.roles,
isEndpointRbacEnabled,
endpointPermissions,
hasHostIsolationExceptions
)
: getEndpointAuthzInitialState();
if (!canReadEndpointList) {
linksToExclude.push(SecurityPageName.endpoints);
}
if (!canReadActionsLogManagement) {
linksToExclude.push(SecurityPageName.responseActionsHistory);

View file

@ -52,9 +52,13 @@ import {
METADATA_UNITED_TRANSFORM,
} from '../../../../../common/endpoint/constants';
import { useUserPrivileges } from '../../../../common/components/user_privileges';
import { initialUserPrivilegesState as mockInitialUserPrivilegesState } from '../../../../common/components/user_privileges/user_privileges_context';
import {
initialUserPrivilegesState,
initialUserPrivilegesState as mockInitialUserPrivilegesState,
} from '../../../../common/components/user_privileges/user_privileges_context';
import { getUserPrivilegesMockDefaultValue } from '../../../../common/components/user_privileges/__mocks__';
import { ENDPOINT_CAPABILITIES } from '../../../../../common/endpoint/service/response_actions/constants';
import { getEndpointPrivilegesInitialStateMock } from '../../../../common/components/user_privileges/endpoint/mocks';
const mockUserPrivileges = useUserPrivileges as jest.Mock;
// not sure why this can't be imported from '../../../../common/mock/formatted_relative';
@ -820,6 +824,8 @@ describe('when on the endpoint list page', () => {
endpointPrivileges: {
...mockInitialUserPrivilegesState().endpointPrivileges,
canReadActionsLogManagement: false,
canReadEndpointList: true,
canAccessFleet: true,
},
});
const renderResult = await renderAndWaitForData();
@ -839,6 +845,8 @@ describe('when on the endpoint list page', () => {
endpointPrivileges: {
...mockInitialUserPrivilegesState().endpointPrivileges,
canReadActionsLogManagement: false,
canReadEndpointList: true,
canAccessFleet: true,
},
});
reactTestingLibrary.act(() => {
@ -1231,6 +1239,14 @@ describe('when on the endpoint list page', () => {
});
describe('required transform failed banner', () => {
beforeEach(() => {
mockUserPrivileges.mockReturnValue(getUserPrivilegesMockDefaultValue());
});
afterEach(() => {
jest.clearAllMocks();
mockUserPrivileges.mockReset();
});
it('is not displayed when transform state is not failed', () => {
const transforms: TransformStats[] = [
{
@ -1315,4 +1331,64 @@ describe('when on the endpoint list page', () => {
expect(banner).toHaveTextContent(transforms[1].id);
});
});
describe('endpoint list onboarding screens with RBAC', () => {
beforeEach(() => {
setEndpointListApiMockImplementation(coreStart.http, {
endpointsResults: [],
endpointPackagePolicies: mockPolicyResultList({ total: 3 }).items,
});
});
afterEach(() => {
mockUserPrivileges.mockReset();
});
it('user has endpoint list ALL and fleet All and can view entire onboarding screen', async () => {
mockUserPrivileges.mockReturnValue({
...initialUserPrivilegesState(),
endpointPrivileges: getEndpointPrivilegesInitialStateMock({
canWriteEndpointList: true,
canAccessFleet: true,
}),
});
const renderResult = render();
await reactTestingLibrary.act(async () => {
await middlewareSpy.waitForAction('serverReturnedPoliciesForOnboarding');
});
const onboardingSteps = await renderResult.findByTestId('onboardingSteps');
expect(onboardingSteps).not.toBeNull();
});
it('user has endpoint list READ and fleet All and can view entire onboarding screen', async () => {
mockUserPrivileges.mockReturnValue({
...initialUserPrivilegesState(),
endpointPrivileges: getEndpointPrivilegesInitialStateMock({
canReadEndpointList: true,
canAccessFleet: true,
}),
});
const renderResult = render();
await reactTestingLibrary.act(async () => {
await middlewareSpy.waitForAction('serverReturnedPoliciesForOnboarding');
});
const onboardingSteps = await renderResult.findByTestId('onboardingSteps');
expect(onboardingSteps).not.toBeNull();
});
it('user has endpoint list ALL/READ and fleet NONE and can view a modified onboarding screen with no actions link to fleet', async () => {
mockUserPrivileges.mockReturnValue({
...initialUserPrivilegesState(),
endpointPrivileges: getEndpointPrivilegesInitialStateMock({
canReadEndpointList: true,
canAccessFleet: false,
}),
});
const renderResult = render();
await reactTestingLibrary.act(async () => {
await middlewareSpy.waitForAction('serverReturnedPoliciesForOnboarding');
});
const onboardingSteps = await renderResult.findByTestId('policyOnboardingInstructions');
expect(onboardingSteps).not.toBeNull();
const noPrivilegesPage = await renderResult.findByTestId('noFleetAccess');
expect(noPrivilegesPage).not.toBeNull();
const startButton = renderResult.queryByTestId('onboardingStartButton');
expect(startButton).toBeNull();
});
});
});

View file

@ -31,7 +31,6 @@ import type {
AgentPolicyDetailsDeployAgentAction,
} from '@kbn/fleet-plugin/public';
import { pagePathGetters } from '@kbn/fleet-plugin/public';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { EndpointDetailsFlyout } from './details';
import * as selectors from '../store/selectors';
import { useEndpointSelector } from './hooks';
@ -70,7 +69,8 @@ import { WARNING_TRANSFORM_STATES, APP_UI_ID } from '../../../../../common/const
import type { BackToExternalAppButtonProps } from '../../../components/back_to_external_app_button/back_to_external_app_button';
import { BackToExternalAppButton } from '../../../components/back_to_external_app_button/back_to_external_app_button';
import { ManagementEmptyStateWrapper } from '../../../components/management_empty_state_wrapper';
import { useUserPrivileges } from '../../../../common/components/user_privileges';
import { useKibana } from '../../../../common/lib/kibana';
const MAX_PAGINATED_ITEM = 9999;
const TRANSFORM_URL = '/data/transform';
@ -132,6 +132,11 @@ export const EndpointList = () => {
endpointsTotalError,
metadataTransformStats,
} = useEndpointSelector(selector);
const {
canReadEndpointList,
canAccessFleet,
loading: endpointPrivilegesLoading,
} = useUserPrivileges().endpointPrivileges;
const { search } = useFormatUrl(SecurityPageName.administration);
const { search: searchParams } = useLocation();
const { getAppUrl } = useAppUrl();
@ -173,6 +178,23 @@ export const EndpointList = () => {
<BackToExternalAppButton {...backLinkOptions} data-test-subj="endpointListBackLink" />
);
const missingFleetAccessInfo = useMemo(() => {
return (
<EuiText size="s" color="subdued" data-test-subj="noFleetAccess">
<FormattedMessage
id="xpack.securitySolution.endpoint.onboarding.enableFleetAccess"
defaultMessage="Deploying Agents for the first time requires Fleet access. For more information, "
/>
<EuiLink external href={`${services.docLinks.links.securitySolution.privileges}`}>
<FormattedMessage
id="xpack.securitySolution.endpoint.policyList.onboardingDocsLink"
defaultMessage="view the Elastic Security documentation"
/>
</EuiLink>
</EuiText>
);
}, [services.docLinks.links.securitySolution.privileges]);
useEffect(() => {
// if no endpoint policy, skip transform check
if (!shouldCheckTransforms || !policyItems || !policyItems.length) {
@ -547,6 +569,16 @@ export const EndpointList = () => {
rowProps={setTableRowProps}
/>
);
} else if (canReadEndpointList && !canAccessFleet) {
return (
<ManagementEmptyStateWrapper>
<PolicyEmptyState
loading={endpointPrivilegesLoading}
actionHidden
additionalInfo={missingFleetAccessInfo}
/>
</ManagementEmptyStateWrapper>
);
} else if (!policyItemsLoading && policyItems && policyItems.length > 0) {
return (
<HostsEmptyState
@ -581,6 +613,10 @@ export const EndpointList = () => {
handleSelectableOnChange,
selectionOptions,
handleCreatePolicyClick,
canAccessFleet,
canReadEndpointList,
endpointPrivilegesLoading,
missingFleetAccessInfo,
]);
const hasListData = listData && listData.length > 0;
@ -633,7 +669,7 @@ export const EndpointList = () => {
docsPage: (
<EuiLink
data-test-subj="failed-transform-docs-link"
href={services?.docLinks?.links.endpoints.troubleshooting}
href={services.docLinks.links.endpoints.troubleshooting}
target="_blank"
>
<FormattedMessage
@ -647,7 +683,7 @@ export const EndpointList = () => {
<EuiSpacer size="s" />
</>
);
}, [metadataTransformStats, services?.docLinks?.links.endpoints.troubleshooting]);
}, [metadataTransformStats, services.docLinks.links.endpoints.troubleshooting]);
const transformFailedCallout = useMemo(() => {
if (!showTransformFailedCallout) {
@ -712,7 +748,7 @@ export const EndpointList = () => {
appPath={`#${pagePathGetters.agent_list({
kuery: 'packages : "endpoint"',
})}`}
href={`${services?.application?.getUrlForApp(
href={`${services.application.getUrlForApp(
'fleet'
)}#${pagePathGetters.agent_list({
kuery: 'packages : "endpoint"',

View file

@ -101,7 +101,7 @@ describe('when in the Administration tab', () => {
describe.skip('when the user has permissions', () => {
it('should display the Management view if user has privileges', async () => {
useUserPrivilegesMock.mockReturnValue({
endpointPrivileges: { loading: false, canReadEndpointList: true },
endpointPrivileges: { loading: false, canReadEndpointList: true, canAccessFleet: true },
});
expect(await render().findByTestId('endpointPage')).toBeTruthy();