From 2edbe1e169fab0173b6e9c27518a39101e1715db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Tue, 10 Dec 2024 11:46:03 +0100 Subject: [PATCH] [EDR Workflows] Fix Endpoint list RBAC problems (#199803) ## Summary This PR fixes multiple Endpoint list privilege issues. It can be reviewed commit-by-commit so the fixes are mostly separated (although some solutions and tests are reused, hence the reason to have them in one pr): - a3311ad2d05f139a91f0d39816e63c41edc26d80 fixes issue when during onboarding (no hosts, policies are indiferent) calls are made to `GET api/fleet/package_policies` without correct privilege (needs policy management READ or fleet:READ+integration:READ), and causes `Forbidden` page. ([issue](https://github.com/elastic/security-team/issues/10581)) _UI_: we display the usual 'onboarding without correct privileges' UI for users image - 63ca0113e9177521c23088a23eb252f78336f49b fixes issue when during onboarding (no hosts, no policies) the `Add Elastic Defend` button was shown when user had `Fleet:ALL` and `Integrations:READ` privilege, while both should be `ALL` in order to be able to create an integration policy ([issue](https://github.com/elastic/security-team/issues/10765)) _UI_: the 'Add Elastic Defend' button is hidden, so the result is the same as above https://github.com/user-attachments/assets/87fe3a95-131d-484b-8ca0-d06c4caafee1 - ffafa14f7d42ec909f7207cc7b0ef9b060fed720 fixes issue when after having hosts in Endpoint list and we're calling `POST api/fleet/package_policies/_bulk_get` without privilege (needs policy management READ or fleet:READ+integration:READ), which does not cause any visible issue, but is logged to dev console ([issue](https://github.com/elastic/security-team/issues/10580)) some additions: - c7021b38b9d94184797f762d51c6a81959e2abd6 adds an acceptance test for all 3 issues above, with failing test run [here](https://buildkite.com/elastic/kibana-pull-request/builds/250428#019320cf-c433-4979-a998-d0f8b8f7be16). - 8e108477bd12112aff56d24278904ac53379381f enables policy list integration test, this closes #169133 ### Checklist Delete any items that are not applicable to this PR. - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed --------- Co-authored-by: Elastic Machine (cherry picked from commit 2fa8f47064c9aeac378f9c547dc13482de7cb566) --- .../endpoint/service/authz/authz.test.ts | 135 +++++++++--- .../common/endpoint/service/authz/authz.ts | 32 +++ .../common/endpoint/types/authz.ts | 2 + .../components/management_empty_state.tsx | 12 +- .../endpoints_rbac_mocked_data.cy.ts | 201 ++++++++++++++++++ .../endpoint_hosts/store/middleware.test.ts | 134 ++++++++++-- .../pages/endpoint_hosts/store/middleware.ts | 51 +++-- .../pages/endpoint_hosts/view/index.test.tsx | 5 + .../integration_tests/policy_list.test.tsx | 95 ++++++--- 9 files changed, 568 insertions(+), 99 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/cypress/e2e/endpoint_list/endpoints_rbac_mocked_data.cy.ts 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 3772635ef2d33..fc616d9a073ea 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 @@ -5,7 +5,11 @@ * 2.0. */ -import { calculateEndpointAuthz, getEndpointAuthzInitialState } from './authz'; +import { + calculateEndpointAuthz, + canFetchPackageAndAgentPolicies, + getEndpointAuthzInitialState, +} from './authz'; import type { FleetAuthz } from '@kbn/fleet-plugin/common'; import { createFleetAuthzMock } from '@kbn/fleet-plugin/common/mocks'; import { createLicenseServiceMock } from '../../../license/mocks'; @@ -15,6 +19,7 @@ import { RESPONSE_CONSOLE_ACTION_COMMANDS_TO_RBAC_FEATURE_CONTROL, type ResponseConsoleRbacControls, } from '../response_actions/constants'; +import type { Capabilities } from '@kbn/core-capabilities-common'; describe('Endpoint Authz service', () => { let licenseService: ReturnType; @@ -91,48 +96,53 @@ describe('Endpoint Authz service', () => { ); }); - it('should not give canAccessFleet if `fleet.all` is false', () => { - fleetAuthz.fleet.all = false; - expect(calculateEndpointAuthz(licenseService, fleetAuthz, userRoles).canAccessFleet).toBe( - false - ); - }); + describe('Fleet', () => { + [true, false].forEach((value) => { + it(`should set canAccessFleet to ${value} if \`fleet.all\` is ${value}`, () => { + fleetAuthz.fleet.all = value; + expect(calculateEndpointAuthz(licenseService, fleetAuthz, userRoles).canAccessFleet).toBe( + value + ); + }); - it('should not give canReadFleetAgents if `fleet.readAgents` is false', () => { - fleetAuthz.fleet.readAgents = false; - expect(calculateEndpointAuthz(licenseService, fleetAuthz, userRoles).canReadFleetAgents).toBe( - false - ); - }); + it(`should set canReadFleetAgents to ${value} if \`fleet.readAgents\` is ${value}`, () => { + fleetAuthz.fleet.readAgents = value; + expect( + calculateEndpointAuthz(licenseService, fleetAuthz, userRoles).canReadFleetAgents + ).toBe(value); + }); - it('should not give canWriteFleetAgents if `fleet.allAgents` is false', () => { - fleetAuthz.fleet.allAgents = false; - expect( - calculateEndpointAuthz(licenseService, fleetAuthz, userRoles).canWriteFleetAgents - ).toBe(false); - }); + it(`should set canWriteFleetAgents to ${value} if \`fleet.allAgents\` is ${value}`, () => { + fleetAuthz.fleet.allAgents = value; + expect( + calculateEndpointAuthz(licenseService, fleetAuthz, userRoles).canWriteFleetAgents + ).toBe(value); + }); - it('should not give canReadFleetAgentPolicies if `fleet.readAgentPolicies` is false', () => { - fleetAuthz.fleet.readAgentPolicies = false; - expect( - calculateEndpointAuthz(licenseService, fleetAuthz, userRoles).canReadFleetAgentPolicies - ).toBe(false); + it(`should set canReadFleetAgentPolicies to ${value} if \`fleet.readAgentPolicies\` is ${value}`, () => { + fleetAuthz.fleet.readAgentPolicies = value; + expect( + calculateEndpointAuthz(licenseService, fleetAuthz, userRoles).canReadFleetAgentPolicies + ).toBe(value); + }); + + it(`should set canWriteIntegrationPolicies to ${value} if \`integrations.writeIntegrationPolicies\` is ${value}`, () => { + fleetAuthz.integrations.writeIntegrationPolicies = value; + expect( + calculateEndpointAuthz(licenseService, fleetAuthz, userRoles) + .canWriteIntegrationPolicies + ).toBe(value); + }); + }); }); - it('should not give canAccessEndpointManagement if not superuser', () => { + it('should set canAccessEndpointManagement if not superuser', () => { userRoles = []; expect( calculateEndpointAuthz(licenseService, fleetAuthz, userRoles).canAccessEndpointManagement ).toBe(false); }); - it('should give canAccessFleet if `fleet.all` is true', () => { - fleetAuthz.fleet.all = true; - expect(calculateEndpointAuthz(licenseService, fleetAuthz, userRoles).canAccessFleet).toBe( - true - ); - }); - it('should give canAccessEndpointManagement if superuser', () => { userRoles = ['superuser']; expect( @@ -303,6 +313,7 @@ describe('Endpoint Authz service', () => { canReadFleetAgentPolicies: false, canReadFleetAgents: false, canWriteFleetAgents: false, + canWriteIntegrationPolicies: false, canAccessEndpointActionsLogManagement: false, canAccessEndpointManagement: false, canCreateArtifactsByPolicy: false, @@ -336,4 +347,64 @@ describe('Endpoint Authz service', () => { }); }); }); + + describe('canFetchPackageAndAgentPolicies()', () => { + describe('without granular Fleet permissions', () => { + it.each` + readFleet | readIntegrations | readPolicyManagement | result + ${false} | ${false} | ${false} | ${false} + ${true} | ${false} | ${false} | ${false} + ${false} | ${true} | ${false} | ${false} + ${true} | ${true} | ${false} | ${true} + ${false} | ${false} | ${true} | ${true} + ${true} | ${false} | ${true} | ${true} + ${false} | ${true} | ${true} | ${true} + ${true} | ${true} | ${true} | ${true} + `( + 'should return $result when readFleet is $readFleet, readIntegrations is $readIntegrations and readPolicyManagement is $readPolicyManagement', + ({ readFleet, readIntegrations, readPolicyManagement, result }) => { + const capabilities: Partial = { + siem: { readPolicyManagement }, + fleetv2: { read: readFleet }, + fleet: { read: readIntegrations }, + }; + + expect(canFetchPackageAndAgentPolicies(capabilities as Capabilities)).toBe(result); + } + ); + }); + + describe('with granular Fleet permissions', () => { + it.each` + readFleet | readAgentPolicies | readIntegrations | readPolicyManagement | result + ${false} | ${false} | ${false} | ${false} | ${false} + ${false} | ${false} | ${true} | ${false} | ${false} + ${false} | ${false} | ${false} | ${true} | ${true} + ${false} | ${false} | ${true} | ${true} | ${true} + ${false} | ${true} | ${false} | ${false} | ${false} + ${false} | ${true} | ${true} | ${false} | ${false} + ${false} | ${true} | ${false} | ${true} | ${true} + ${false} | ${true} | ${true} | ${true} | ${true} + ${true} | ${false} | ${false} | ${false} | ${false} + ${true} | ${false} | ${true} | ${false} | ${false} + ${true} | ${false} | ${false} | ${true} | ${true} + ${true} | ${false} | ${true} | ${true} | ${true} + ${true} | ${true} | ${false} | ${false} | ${false} + ${true} | ${true} | ${true} | ${false} | ${true} + ${true} | ${true} | ${false} | ${true} | ${true} + ${true} | ${true} | ${true} | ${true} | ${true} + `( + 'should return $result when readAgentPolicies is $readAgentPolicies, readFleet is $readFleet, readIntegrations is $readIntegrations and readPolicyManagement is $readPolicyManagement', + ({ readAgentPolicies, readFleet, readIntegrations, readPolicyManagement, result }) => { + const capabilities: Partial = { + siem: { readPolicyManagement }, + fleetv2: { read: readFleet, agent_policies_read: readAgentPolicies }, + fleet: { read: readIntegrations }, + }; + + expect(canFetchPackageAndAgentPolicies(capabilities as Capabilities)).toBe(result); + } + ); + }); + }); }); 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 7b058e543e28f..f5af3f6c1ef24 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 @@ -8,6 +8,7 @@ import type { ENDPOINT_PRIVILEGES, FleetAuthz } from '@kbn/fleet-plugin/common'; import { omit } from 'lodash'; +import type { Capabilities } from '@kbn/core-capabilities-common'; import type { ProductFeaturesService } from '../../../../server/lib/product_features_service'; import { RESPONSE_CONSOLE_ACTION_COMMANDS_TO_REQUIRED_AUTHZ } from '../response_actions/constants'; import type { LicenseService } from '../../../license'; @@ -99,10 +100,19 @@ export const calculateEndpointAuthz = ( const authz: EndpointAuthz = { canWriteSecuritySolution, canReadSecuritySolution, + + // --------------------------------------------------------- + // Coming from Fleet authz + // --------------------------------------------------------- canAccessFleet: fleetAuthz?.fleet.all ?? false, canReadFleetAgentPolicies: fleetAuthz?.fleet.readAgentPolicies ?? false, canWriteFleetAgents: fleetAuthz?.fleet.allAgents ?? false, canReadFleetAgents: fleetAuthz?.fleet.readAgents ?? false, + canWriteIntegrationPolicies: fleetAuthz?.integrations.writeIntegrationPolicies ?? false, + + // --------------------------------------------------------- + // Endpoint & policy management + // --------------------------------------------------------- canAccessEndpointManagement: hasEndpointManagementAccess, // TODO: is this one deprecated? it is the only place we need to check for superuser. canCreateArtifactsByPolicy: isPlatinumPlusLicense, canWriteEndpointList, @@ -166,6 +176,7 @@ export const getEndpointAuthzInitialState = (): EndpointAuthz => { canReadFleetAgentPolicies: false, canReadFleetAgents: false, canWriteFleetAgents: false, + canWriteIntegrationPolicies: false, canAccessEndpointActionsLogManagement: false, canAccessEndpointManagement: false, canCreateArtifactsByPolicy: false, @@ -198,3 +209,24 @@ export const getEndpointAuthzInitialState = (): EndpointAuthz => { canWriteEndpointExceptions: false, }; }; + +/** + * Duplicate logic to calculate if user has privilege to fetch Agent Policies, + * working only with Capabilities, in order to be able to use it e.g. in middleware. + * + * The logic works with Fleet granular privileges (`subfeaturePrivileges`) both enabled and disabled. + * + * @param capabilities Capabilities from coreStart.application + */ +export const canFetchPackageAndAgentPolicies = (capabilities: Capabilities): boolean => { + const canReadPolicyManagement = Boolean(capabilities.siem?.readPolicyManagement); + + const fleetv2 = capabilities.fleetv2; + const canReadFleetAgentPolicies = Boolean( + fleetv2?.read && (fleetv2?.agent_policies_read ?? true) + ); + + const canReadIntegrations = Boolean(capabilities.fleet?.read); + + return canReadPolicyManagement || (canReadFleetAgentPolicies && canReadIntegrations); +}; diff --git a/x-pack/plugins/security_solution/common/endpoint/types/authz.ts b/x-pack/plugins/security_solution/common/endpoint/types/authz.ts index 6a326479ce8ae..45fc9595405b7 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/authz.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/authz.ts @@ -22,6 +22,8 @@ export interface EndpointAuthz { canReadFleetAgents: boolean; /** If the user has permissions to write Fleet Agents */ canWriteFleetAgents: boolean; + /** If the user has permissions to write Integration policies in the Fleet app */ + canWriteIntegrationPolicies: boolean; /** If the user has permissions to access Endpoint management (includes check to ensure they also have access to fleet) */ canAccessEndpointManagement: boolean; /** If the user has permissions to access Actions Log management and also has a platinum license (used for endpoint details flyout) */ diff --git a/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx b/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx index 2408dad4f39f3..7a1bdfa864c34 100644 --- a/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx +++ b/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx @@ -74,7 +74,11 @@ const PolicyEmptyState = React.memo<{ policyEntryPoint?: boolean; }>(({ loading, onActionClick, actionDisabled, policyEntryPoint = false }) => { const docLinks = useKibana().services.docLinks; - const { canAccessFleet, loading: authzLoading } = useUserPrivileges().endpointPrivileges; + const { + canAccessFleet, + canWriteIntegrationPolicies, + loading: authzLoading, + } = useUserPrivileges().endpointPrivileges; return (
@@ -134,7 +138,7 @@ const PolicyEmptyState = React.memo<{ {authzLoading && } - {!authzLoading && canAccessFleet && ( + {!authzLoading && canAccessFleet && canWriteIntegrationPolicies && ( <> @@ -156,7 +160,9 @@ const PolicyEmptyState = React.memo<{ )} - {!authzLoading && !canAccessFleet && } + {!authzLoading && !(canAccessFleet && canWriteIntegrationPolicies) && ( + + )} diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/endpoint_list/endpoints_rbac_mocked_data.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/endpoint_list/endpoints_rbac_mocked_data.cy.ts new file mode 100644 index 0000000000000..62786effd8303 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/endpoint_list/endpoints_rbac_mocked_data.cy.ts @@ -0,0 +1,201 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PACKAGE_POLICY_API_ROUTES } from '@kbn/fleet-plugin/common/constants/routes'; +import type { IndexedFleetEndpointPolicyResponse } from '../../../../../common/endpoint/data_loaders/index_fleet_endpoint_policy'; +import { getT1Analyst } from '../../../../../scripts/endpoint/common/roles_users'; +import { APP_ENDPOINTS_PATH } from '../../../../../common/constants'; +import type { ReturnTypeFromChainable } from '../../types'; +import { indexEndpointHosts } from '../../tasks/index_endpoint_hosts'; +import { login } from '../../tasks/login'; +import { loadPage } from '../../tasks/common'; + +describe('Endpoints RBAC', { tags: ['@ess'] }, () => { + type Privilege = 'all' | 'read' | 'none'; + const PRIVILEGES: Privilege[] = ['none', 'read', 'all']; + + const loginWithCustomRole: (privileges: { + integrationsPrivilege?: Privilege; + fleetPrivilege?: Privilege; + endpointPolicyManagementPrivilege?: Privilege; + }) => void = ({ + integrationsPrivilege = 'none', + fleetPrivilege = 'none', + endpointPolicyManagementPrivilege = 'none', + }) => { + const base = getT1Analyst(); + + const customRole: typeof base = { + ...base, + kibana: [ + { + ...base.kibana[0], + feature: { + ...base.kibana[0].feature, + siem: [ + ...base.kibana[0].feature.siem, + `endpoint_list_all`, + `policy_management_${endpointPolicyManagementPrivilege}`, + ], + fleet: [integrationsPrivilege], + fleetv2: [fleetPrivilege], + }, + }, + ], + }; + + login.withCustomRole({ name: 'customRole', ...customRole }); + }; + + beforeEach(() => { + login(); + }); + + describe('neither Defend policy nor hosts are present', () => { + for (const endpointPolicyManagementPrivilege of PRIVILEGES) { + describe(`endpoint policy management privilege is ${endpointPolicyManagementPrivilege}`, () => { + for (const fleetPrivilege of PRIVILEGES) { + for (const integrationsPrivilege of PRIVILEGES) { + const shouldAllowOnboarding = + fleetPrivilege === 'all' && integrationsPrivilege === 'all'; + + it(`should show onboarding screen ${ + shouldAllowOnboarding ? 'with' : 'without' + } 'Add Elastic Defend' button with fleet:${fleetPrivilege} and integrations:${integrationsPrivilege}`, () => { + loginWithCustomRole({ + endpointPolicyManagementPrivilege, + fleetPrivilege, + integrationsPrivilege, + }); + + loadPage(APP_ENDPOINTS_PATH); + + cy.getByTestSubj('policyOnboardingInstructions').should('exist'); + if (shouldAllowOnboarding) { + cy.getByTestSubj('onboardingStartButton').should('exist'); + } else { + cy.getByTestSubj('onboardingStartButton').should('not.exist'); + } + }); + } + } + }); + } + }); + + describe('Defend policy is present, but no hosts', () => { + let loadedPolicyData: IndexedFleetEndpointPolicyResponse; + + before(() => { + cy.task( + 'indexFleetEndpointPolicy', + { policyName: 'tests-serverless' }, + { timeout: 5 * 60 * 1000 } + ).then((res) => { + const response = res as IndexedFleetEndpointPolicyResponse; + loadedPolicyData = response; + }); + }); + + after(() => { + if (loadedPolicyData) { + cy.task('deleteIndexedFleetEndpointPolicies', loadedPolicyData); + } + }); + + for (const endpointPolicyManagementPrivilege of PRIVILEGES) { + describe(`endpoint policy management privilege is ${endpointPolicyManagementPrivilege}`, () => { + for (const fleetPrivilege of PRIVILEGES) { + for (const integrationsPrivilege of PRIVILEGES) { + const shouldShowOnboardingSteps = + (fleetPrivilege === 'all' && integrationsPrivilege === 'read') || + (fleetPrivilege === 'all' && integrationsPrivilege === 'all'); + + it(`should ${ + shouldShowOnboardingSteps ? '' : ' NOT ' + } show onboarding steps with fleet:${fleetPrivilege} and integrations:${integrationsPrivilege}`, () => { + loginWithCustomRole({ + endpointPolicyManagementPrivilege, + fleetPrivilege, + integrationsPrivilege, + }); + + loadPage(APP_ENDPOINTS_PATH); + + if (shouldShowOnboardingSteps) { + cy.getByTestSubj('emptyHostsTable').should('exist'); + cy.getByTestSubj('onboardingSteps').should('exist'); + } else { + // without correct privileges, fall back to empty policy table note showing that Fleet privilege is required + cy.getByTestSubj('emptyPolicyTable').should('exist'); + cy.getByTestSubj('onboardingStartButton').should('not.exist'); + } + }); + } + } + }); + } + }); + + describe('some hosts are enrolled', () => { + let endpointData: ReturnTypeFromChainable; + + before(() => { + indexEndpointHosts({ count: 1 }).then((indexEndpoints) => { + endpointData = indexEndpoints; + }); + }); + + after(() => { + if (endpointData) { + endpointData.cleanup(); + // @ts-expect-error ignore setting to undefined + endpointData = undefined; + } + }); + + beforeEach(() => { + // if there is a request towards this API, it should return 200 + cy.intercept(PACKAGE_POLICY_API_ROUTES.BULK_GET_PATTERN, (req) => { + req.on('response', (res) => { + expect(res.statusCode).to.equal(200); + }); + }); + }); + + for (const endpointPolicyManagementPrivilege of PRIVILEGES) { + describe(`endpoint policy management privilege is ${endpointPolicyManagementPrivilege}`, () => { + for (const fleetPrivilege of PRIVILEGES) { + for (const integrationsPrivilege of PRIVILEGES) { + const shouldProvidePolicyLink = endpointPolicyManagementPrivilege !== 'none'; + + it(`should show Endpoint list ${ + shouldProvidePolicyLink ? 'with' : 'without' + } link to Endpoint Policy with fleet:${fleetPrivilege} and integrations:${integrationsPrivilege}`, () => { + loginWithCustomRole({ + endpointPolicyManagementPrivilege, + fleetPrivilege, + integrationsPrivilege, + }); + + loadPage(APP_ENDPOINTS_PATH); + + cy.getByTestSubj('policyNameCellLink').should('exist'); + cy.getByTestSubj('policyNameCellLink').within(() => { + if (shouldProvidePolicyLink) { + cy.get('a').should('have.attr', 'href'); + } else { + cy.get('a').should('not.exist'); + } + }); + }); + } + } + }); + } + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts index a737ccd77884c..a6efa0f95c6d5 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts @@ -42,11 +42,14 @@ import { } from '../../../../common/lib/endpoint/endpoint_isolation/mocks'; import { endpointPageHttpMock, failedTransformStateMock } from '../mocks'; import { HOST_METADATA_LIST_ROUTE } from '../../../../../common/endpoint/constants'; +import { INGEST_API_PACKAGE_POLICIES } from '../../../services/policies/ingest'; +import { canFetchPackageAndAgentPolicies } from '../../../../../common/endpoint/service/authz/authz'; +const mockSendBulkGetPackagePolicies = jest.fn(); jest.mock('../../../services/policies/ingest', () => ({ sendGetAgentConfigList: () => Promise.resolve({ items: [] }), sendGetAgentPolicyList: () => Promise.resolve({ items: [] }), - sendBulkGetPackagePolicies: () => Promise.resolve({ items: [] }), + sendBulkGetPackagePolicies: () => mockSendBulkGetPackagePolicies(), sendGetEndpointSecurityPackage: () => Promise.resolve({ version: '1.1.1' }), })); @@ -57,6 +60,12 @@ jest.mock('rxjs', () => ({ firstValueFrom: () => mockFirstValueFrom(), })); +jest.mock('../../../../../common/endpoint/service/authz/authz', () => ({ + ...jest.requireActual('../../../../../common/endpoint/service/authz/authz'), + canFetchPackageAndAgentPolicies: jest.fn(), +})); +const canFetchAgentPoliciesMock = canFetchPackageAndAgentPolicies as jest.Mock; + type EndpointListStore = Store, Immutable>; describe('endpoint list middleware', () => { @@ -71,8 +80,10 @@ describe('endpoint list middleware', () => { let actionSpyMiddleware; let history: History; - const getEndpointListApiResponse = (): MetadataListResponse => { - return mockEndpointResultList({ pageSize: 1, page: 0, total: 10 }); + const getEndpointListApiResponse = ( + options: Partial[0]> = {} + ): MetadataListResponse => { + return mockEndpointResultList({ pageSize: 1, page: 0, total: 10, ...options }); }; const dispatchUserChangedUrlToEndpointList = (locationOverrides: Partial = {}) => { @@ -99,25 +110,112 @@ describe('endpoint list middleware', () => { dispatch = store.dispatch; history = createBrowserHistory(); getKibanaServicesMock.mockReturnValue(fakeCoreStart); + canFetchAgentPoliciesMock.mockReturnValue(false); + mockSendBulkGetPackagePolicies.mockResolvedValue({ items: [] }); }); - it('handles `userChangedUrl`', async () => { - endpointPageHttpMock(fakeHttpServices); - const apiResponse = getEndpointListApiResponse(); - fakeHttpServices.get.mockResolvedValue(apiResponse); - expect(fakeHttpServices.get).not.toHaveBeenCalled(); + describe('handles `userChangedUrl`', () => { + describe('when there are hosts', () => { + let apiResponse: MetadataListResponse; - dispatchUserChangedUrlToEndpointList(); - await waitForAction('serverReturnedEndpointList'); - expect(fakeHttpServices.get).toHaveBeenNthCalledWith(1, HOST_METADATA_LIST_ROUTE, { - query: { - page: '0', - pageSize: '10', - kuery: '', - }, - version: '2023-10-31', + beforeEach(() => { + endpointPageHttpMock(fakeHttpServices); + apiResponse = getEndpointListApiResponse(); + fakeHttpServices.get.mockResolvedValue(apiResponse); + }); + + it('should not fetch agent policies if there are hosts', async () => { + dispatchUserChangedUrlToEndpointList(); + + await Promise.all([ + waitForAction('serverReturnedEndpointList'), + waitForAction('serverReturnedEndpointExistValue', { + validate: ({ payload }) => payload === true, + }), + waitForAction('serverCancelledPolicyItemsLoading'), + ]); + expect(fakeHttpServices.get).toHaveBeenNthCalledWith(1, HOST_METADATA_LIST_ROUTE, { + query: { + page: '0', + pageSize: '10', + kuery: '', + }, + version: '2023-10-31', + }); + expect(listData(getState())).toEqual(apiResponse.data); + expect(fakeHttpServices.get).not.toHaveBeenCalledWith( + INGEST_API_PACKAGE_POLICIES, + expect.objectContaining({}) + ); + }); + + describe('fetching non-existing policies', () => { + it('should not fetch package policies without required privileges', async () => { + canFetchAgentPoliciesMock.mockReturnValue(false); + + dispatchUserChangedUrlToEndpointList(); + + await waitForAction('serverFinishedInitialization'); + expect(mockSendBulkGetPackagePolicies).not.toBeCalled(); + }); + + it('should fetch package policies with required privileges', async () => { + canFetchAgentPoliciesMock.mockReturnValue(true); + + dispatchUserChangedUrlToEndpointList(); + + await Promise.all([ + waitForAction('serverFinishedInitialization'), + waitForAction('serverReturnedEndpointNonExistingPolicies'), + ]); + expect(mockSendBulkGetPackagePolicies).toBeCalled(); + }); + }); + }); + + describe('when there are no hosts', () => { + beforeEach(() => { + endpointPageHttpMock(fakeHttpServices); + const apiResponse = getEndpointListApiResponse({ total: 0 }); + fakeHttpServices.get.mockResolvedValue(apiResponse); + }); + + it('should NOT fetch agent policies without required privileges', async () => { + canFetchAgentPoliciesMock.mockReturnValue(false); + + dispatchUserChangedUrlToEndpointList(); + + await Promise.all([ + waitForAction('serverReturnedEndpointList'), + waitForAction('serverReturnedEndpointExistValue', { + validate: ({ payload }) => payload === false, + }), + waitForAction('serverCancelledPolicyItemsLoading'), + ]); + expect(fakeHttpServices.get).not.toHaveBeenCalledWith( + INGEST_API_PACKAGE_POLICIES, + expect.objectContaining({}) + ); + }); + + it('should fetch agent policies with required privileges', async () => { + canFetchAgentPoliciesMock.mockReturnValue(true); + + dispatchUserChangedUrlToEndpointList(); + + await Promise.all([ + waitForAction('serverReturnedEndpointList'), + waitForAction('serverReturnedEndpointExistValue', { + validate: ({ payload }) => payload === false, + }), + waitForAction('serverReturnedPoliciesForOnboarding'), + ]); + expect(fakeHttpServices.get).toHaveBeenCalledWith( + INGEST_API_PACKAGE_POLICIES, + expect.objectContaining({}) + ); + }); }); - expect(listData(getState())).toEqual(apiResponse.data); }); it('handles `appRequestedEndpointList`', async () => { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts index f91e74983e5a2..3d4d1baad459e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts @@ -14,6 +14,7 @@ import type { IndexFieldsStrategyRequest, IndexFieldsStrategyResponse, } from '@kbn/timelines-plugin/common'; +import { canFetchPackageAndAgentPolicies } from '../../../../../common/endpoint/service/authz/authz'; import type { IsolationRouteRequestBody, UnisolationRouteRequestBody, @@ -320,7 +321,7 @@ async function endpointListMiddleware({ payload: endpointResponse, }); - fetchNonExistingPolicies({ http: coreStart.http, hosts: endpointResponse.data, store }); + fetchNonExistingPolicies({ coreStart, hosts: endpointResponse.data, store }); } catch (error) { dispatch({ type: 'serverFailedToReturnEndpointList', @@ -366,25 +367,31 @@ async function endpointListMiddleware({ payload: false, }); - try { - const policyDataResponse: GetPolicyListResponse = - await sendGetEndpointSpecificPackagePolicies(http, { - query: { - perPage: 50, // Since this is an onboarding flow, we'll cap at 50 policies. - page: 1, + if (canFetchPackageAndAgentPolicies(coreStart.application.capabilities)) { + try { + const policyDataResponse: GetPolicyListResponse = + await sendGetEndpointSpecificPackagePolicies(http, { + query: { + perPage: 50, // Since this is an onboarding flow, we'll cap at 50 policies. + page: 1, + }, + }); + + dispatch({ + type: 'serverReturnedPoliciesForOnboarding', + payload: { + policyItems: policyDataResponse.items, }, }); - - dispatch({ - type: 'serverReturnedPoliciesForOnboarding', - payload: { - policyItems: policyDataResponse.items, - }, - }); - } catch (error) { + } catch (error) { + dispatch({ + type: 'serverFailedToReturnPoliciesForOnboarding', + payload: error.body ?? error, + }); + } + } else { dispatch({ - type: 'serverFailedToReturnPoliciesForOnboarding', - payload: error.body ?? error, + type: 'serverCancelledPolicyItemsLoading', }); } } else { @@ -440,16 +447,20 @@ export async function handleLoadMetadataTransformStats(http: HttpStart, store: E async function fetchNonExistingPolicies({ store, hosts, - http, + coreStart, }: { store: EndpointPageStore; hosts: HostResultList['hosts']; - http: HttpStart; + coreStart: CoreStart; }) { + if (!canFetchPackageAndAgentPolicies(coreStart.application.capabilities)) { + return; + } + const { getState, dispatch } = store; try { const missingPolicies = await getNonExistingPoliciesForEndpointList( - http, + coreStart.http, hosts, nonExistingPolicies(getState()) ); 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 57b0dde41177d..c3a5c13512133 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 @@ -192,6 +192,11 @@ describe('when on the endpoint list page', () => { // to use services that we have in our test `mockedContext` (useToasts as jest.Mock).mockReturnValue(coreStart.notifications.toasts); (useKibana as jest.Mock).mockReturnValue({ services: mockedContext.startServices }); + + coreStart.application.capabilities = { + ...coreStart.application.capabilities, + siem: { readPolicyManagement: true }, + }; }); it('should NOT display timeline', async () => { diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/integration_tests/policy_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/integration_tests/policy_list.test.tsx index b140134a0e6ac..5a1e84f67cec8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/integration_tests/policy_list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/integration_tests/policy_list.test.tsx @@ -18,6 +18,10 @@ import { APP_UI_ID } from '../../../../../../common/constants'; import { useUserPrivileges } from '../../../../../common/components/user_privileges'; import userEvent from '@testing-library/user-event'; import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; +import { + mockEndpointResultList, + setEndpointListApiMockImplementation, +} from '../../../endpoint_hosts/store/mock_endpoint_result_list'; jest.mock('../../../../services/policies/policies'); jest.mock('../../../../../common/components/user_privileges'); @@ -25,20 +29,25 @@ jest.mock('../../../../../common/components/user_privileges'); const getPackagePolicies = sendGetEndpointSpecificPackagePolicies as jest.Mock; const useUserPrivilegesMock = useUserPrivileges as jest.Mock; -// Failing: See https://github.com/elastic/kibana/issues/169133 -describe.skip('When on the policy list page', () => { +describe('When on the policy list page', () => { let render: () => ReturnType; let renderResult: ReturnType; let history: AppContextTestRender['history']; + let coreStart: AppContextTestRender['coreStart']; let mockedContext: AppContextTestRender; beforeEach(() => { useUserPrivilegesMock.mockReturnValue({ - endpointPrivileges: { canReadEndpointList: true, canAccessFleet: true, loading: false }, + endpointPrivileges: { + canReadEndpointList: true, + canAccessFleet: true, + canWriteIntegrationPolicies: true, + loading: false, + }, }); mockedContext = createAppRootMockRenderer(); - ({ history } = mockedContext); + ({ history, coreStart } = mockedContext); render = () => (renderResult = mockedContext.render()); }); @@ -56,9 +65,9 @@ describe.skip('When on the policy list page', () => { }); it('should show table with error state', async () => { - expect(renderResult.getByTestId('policyListTable')).toBeTruthy(); + expect(renderResult.getByTestId('policyListTable')).toBeInTheDocument(); await waitFor(() => { - expect(renderResult.getByText(policyListErrorMessage)).toBeTruthy(); + expect(renderResult.getByText(policyListErrorMessage)).toBeInTheDocument(); }); }); }); @@ -71,28 +80,61 @@ describe.skip('When on the policy list page', () => { count: 0, }) ); - render(); - await waitFor(() => { - expect(getPackagePolicies).toHaveBeenCalled(); - }); }); + afterEach(() => { getPackagePolicies.mockReset(); }); - it('should show the empty page', async () => { - await waitFor(() => { - expect(renderResult.getByTestId('emptyPolicyTable')).toBeTruthy(); - }); - }); - it('should show instruction text and a button to add the Endpoint Security integration', async () => { + + it('should show the empty page with onboarding instructions', async () => { + render(); + await waitFor(() => { + expect(renderResult.getByTestId('emptyPolicyTable')).toBeInTheDocument(); + expect(renderResult.getByTestId('policyOnboardingInstructions')).toBeInTheDocument(); expect( renderResult.getByText( - 'From this page, you’ll be able to view and manage the Elastic Defend Integration policies in your environment running Elastic Defend.' + 'From this page, you can view and manage the Elastic Defend integration policies in your environment running Elastic Defend.' ) - ).toBeTruthy(); + ).toBeInTheDocument(); + }); + }); - expect(renderResult.getByTestId('onboardingStartButton')).toBeTruthy(); + it('should show onboarding button with fleet access and integrations write privilege', async () => { + useUserPrivilegesMock.mockReturnValue({ + endpointPrivileges: { canAccessFleet: true, canWriteIntegrationPolicies: true }, + }); + render(); + + await waitFor(() => { + expect(renderResult.getByTestId('policyOnboardingInstructions')).toBeInTheDocument(); + expect(renderResult.getByTestId('onboardingStartButton')).toBeInTheDocument(); + }); + }); + + it('should not show onboarding button with integrations write privilege but without fleet access', async () => { + useUserPrivilegesMock.mockReturnValue({ + endpointPrivileges: { canAccessFleet: false, canWriteIntegrationPolicies: true }, + }); + + render(); + + await waitFor(() => { + expect(renderResult.getByTestId('policyOnboardingInstructions')).toBeInTheDocument(); + expect(renderResult.queryByTestId('onboardingStartButton')).not.toBeInTheDocument(); + }); + }); + + it('should not show onboarding button with fleet access but without integrations write privilege', async () => { + useUserPrivilegesMock.mockReturnValue({ + endpointPrivileges: { canAccessFleet: true, canWriteIntegrationPolicies: false }, + }); + + render(); + + await waitFor(() => { + expect(renderResult.getByTestId('policyOnboardingInstructions')).toBeInTheDocument(); + expect(renderResult.queryByTestId('onboardingStartButton')).not.toBeInTheDocument(); }); }); }); @@ -108,22 +150,20 @@ describe.skip('When on the policy list page', () => { await waitFor(() => { expect(sendGetEndpointSpecificPackagePolicies).toHaveBeenCalled(); expect(getPackagePolicies).toHaveBeenCalled(); - expect(renderResult.getByTestId('policyListTable')).toBeTruthy(); + expect(renderResult.getByTestId('policyListTable')).toBeInTheDocument(); }); }); it('should display the policy list table', () => { - expect(renderResult.getByTestId('policyListTable')).toBeTruthy(); + expect(renderResult.getByTestId('policyListTable')).toBeInTheDocument(); }); it('should show a link for the policy name', () => { const policyNameCells = renderResult.getAllByTestId('policyNameCellLink'); - expect(policyNameCells).toBeTruthy(); expect(policyNameCells.length).toBe(5); }); it('should show an avatar and name for the Created by column', () => { const expectedAvatarName = policies.items[0].created_by; const createdByCells = renderResult.getAllByTestId('created-by-avatar'); const firstCreatedByName = renderResult.getAllByTestId('created-by-name')[0]; - expect(createdByCells).toBeTruthy(); expect(createdByCells.length).toBe(5); expect(createdByCells[0].textContent).toEqual(expectedAvatarName.charAt(0)); expect(firstCreatedByName.textContent).toEqual(expectedAvatarName); @@ -132,7 +172,6 @@ describe.skip('When on the policy list page', () => { const expectedAvatarName = policies.items[0].updated_by; const updatedByCells = renderResult.getAllByTestId('updated-by-avatar'); const firstUpdatedByName = renderResult.getAllByTestId('updated-by-name')[0]; - expect(updatedByCells).toBeTruthy(); expect(updatedByCells.length).toBe(5); expect(updatedByCells[0].textContent).toEqual(expectedAvatarName.charAt(0)); expect(firstUpdatedByName.textContent).toEqual(expectedAvatarName); @@ -158,6 +197,10 @@ describe.skip('When on the policy list page', () => { href: '/app/security/administration/policy', }, }; + setEndpointListApiMockImplementation(coreStart.http, { + endpointsResults: mockEndpointResultList().data, + }); + const endpointCount = renderResult.getAllByTestId('policyEndpointCountLink')[0]; await userEvent.click(endpointCount); @@ -192,7 +235,7 @@ describe.skip('When on the policy list page', () => { }); it('should pass the correct page value to the api', async () => { await waitFor(() => { - expect(renderResult.getByTestId('pagination-button-next')).toBeTruthy(); + expect(renderResult.getByTestId('pagination-button-next')).toBeInTheDocument(); }); await userEvent.click(renderResult.getByTestId('pagination-button-next')); await waitFor(() => { @@ -207,7 +250,7 @@ describe.skip('When on the policy list page', () => { it('should pass the correct pageSize value to the api', async () => { await waitFor(() => { - expect(renderResult.getByTestId('tablePaginationPopoverButton')).toBeTruthy(); + expect(renderResult.getByTestId('tablePaginationPopoverButton')).toBeInTheDocument(); }); await userEvent.click(renderResult.getByTestId('tablePaginationPopoverButton')); await waitForEuiPopoverOpen();