diff --git a/src/platform/packages/shared/kbn-doc-links/src/get_doc_links.ts b/src/platform/packages/shared/kbn-doc-links/src/get_doc_links.ts index e855e2bf71a8e..17f6b04d69058 100644 --- a/src/platform/packages/shared/kbn-doc-links/src/get_doc_links.ts +++ b/src/platform/packages/shared/kbn-doc-links/src/get_doc_links.ts @@ -463,6 +463,7 @@ export const getDocLinks = ({ kibanaBranch, buildFlavor }: GetDocLinkOptions): D installElasticDefend: `${ELASTIC_DOCS}solutions/security/configure-elastic-defend/install-elastic-defend`, avcResults: `https://www.elastic.co/blog/elastic-security-av-comparatives-business-test`, bidirectionalIntegrations: `${ELASTIC_DOCS}solutions/security/endpoint-response-actions/third-party-response-actions`, + endpointExceptions: `${ELASTIC_DOCS}solutions/security/manage-elastic-defend/elastic-endpoint-exceptions`, trustedApps: `${ELASTIC_DOCS}solutions/security/manage-elastic-defend/trusted-applications`, trustedDevices: `${ELASTIC_DOCS}solutions/security/manage-elastic-defend/trusted-devices`, elasticAiFeatures: `${ELASTIC_DOCS}solutions/security/ai`, diff --git a/src/platform/packages/shared/kbn-doc-links/src/types.ts b/src/platform/packages/shared/kbn-doc-links/src/types.ts index ffc894fbfe34c..29378e020114a 100644 --- a/src/platform/packages/shared/kbn-doc-links/src/types.ts +++ b/src/platform/packages/shared/kbn-doc-links/src/types.ts @@ -329,6 +329,7 @@ export interface DocLinks { readonly avcResults: string; readonly bidirectionalIntegrations: string; readonly thirdPartyLlmProviders: string; + readonly endpointExceptions: string; readonly trustedApps: string; readonly trustedDevices: string; readonly elasticAiFeatures: string; diff --git a/x-pack/solutions/security/packages/test-api-clients/supertest/endpoint_management.gen.ts b/x-pack/solutions/security/packages/test-api-clients/supertest/endpoint_management.gen.ts index 5c9c5338e72a2..86ec771ee3198 100644 --- a/x-pack/solutions/security/packages/test-api-clients/supertest/endpoint_management.gen.ts +++ b/x-pack/solutions/security/packages/test-api-clients/supertest/endpoint_management.gen.ts @@ -268,6 +268,18 @@ const securitySolutionApiServiceFactory = (supertest: SuperTest.Agent) => ({ .set(ELASTIC_HTTP_VERSION_HEADER, '2023-10-31') .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana'); }, + getEndpointExceptionsPerPolicyOptIn(kibanaSpace: string = 'default') { + return supertest + .get( + getRouteUrlForSpace( + '/internal/api/endpoint/endpoint_exceptions_per_policy_opt_in', + kibanaSpace + ) + ) + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana'); + }, getEndpointMetadataList(props: GetEndpointMetadataListProps, kibanaSpace: string = 'default') { return supertest .get(getRouteUrlForSpace('/api/endpoint/metadata', kibanaSpace)) @@ -328,6 +340,18 @@ const securitySolutionApiServiceFactory = (supertest: SuperTest.Agent) => ({ .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') .query(props.query); }, + performEndpointExceptionsPerPolicyOptIn(kibanaSpace: string = 'default') { + return supertest + .post( + getRouteUrlForSpace( + '/internal/api/endpoint/endpoint_exceptions_per_policy_opt_in', + kibanaSpace + ) + ) + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana'); + }, /** * Run a script on a host. Currently supported only for some agent types. */ diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/endpoint_exceptions_per_policy_opt_in/endpoint_exceptions_per_policy_opt_in.gen.ts b/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/endpoint_exceptions_per_policy_opt_in/endpoint_exceptions_per_policy_opt_in.gen.ts new file mode 100644 index 0000000000000..b3417407d3b54 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/endpoint_exceptions_per_policy_opt_in/endpoint_exceptions_per_policy_opt_in.gen.ts @@ -0,0 +1,25 @@ +/* + * 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. + */ + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + * + * info: + * title: Endpoint Exceptions Per Policy Opt-In API + * version: 1 + */ + +import { z } from '@kbn/zod/v4'; + +export type GetEndpointExceptionsPerPolicyOptInResponse = z.infer< + typeof GetEndpointExceptionsPerPolicyOptInResponse +>; +export const GetEndpointExceptionsPerPolicyOptInResponse = z.object({ + status: z.boolean(), + reason: z.enum(['newDeployment', 'userOptedIn']).optional(), +}); diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/endpoint_exceptions_per_policy_opt_in/endpoint_exceptions_per_policy_opt_in.schema.yaml b/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/endpoint_exceptions_per_policy_opt_in/endpoint_exceptions_per_policy_opt_in.schema.yaml new file mode 100644 index 0000000000000..d6b9580b8d80d --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/endpoint_exceptions_per_policy_opt_in/endpoint_exceptions_per_policy_opt_in.schema.yaml @@ -0,0 +1,42 @@ +openapi: 3.0.0 +info: + title: Endpoint Exceptions Per Policy Opt-In API + version: '1' +paths: + /internal/api/endpoint/endpoint_exceptions_per_policy_opt_in: + get: + summary: Retrieve endpoint exceptions per policy opt-in + operationId: GetEndpointExceptionsPerPolicyOptIn + x-codegen-enabled: true + x-labels: [] + # TODO: When the feature flag `endpointExceptionsMovedUnderManagement` is enabled, remove empty `x-labels` and un-comment the line below. + # x-labels: [ ess, serverless ] + x-internal: true + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: + - status + properties: + status: + type: boolean + reason: + type: string + enum: [newDeployment, userOptedIn] + + post: + summary: Opt-in to endpoint exceptions per policy + operationId: PerformEndpointExceptionsPerPolicyOptIn + x-codegen-enabled: true + x-labels: [] + # TODO: When the feature flag `endpointExceptionsMovedUnderManagement` is enabled, remove empty `x-labels` and un-comment the line below. + # x-labels: [ ess, serverless ] + x-internal: true + responses: + '200': + description: OK + diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/quickstart_client.gen.ts b/x-pack/solutions/security/plugins/security_solution/common/api/quickstart_client.gen.ts index 113d37ce4dd73..4224606b1461c 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/api/quickstart_client.gen.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/api/quickstart_client.gen.ts @@ -186,6 +186,7 @@ import type { EndpointGetActionsStatusRequestQueryInput, EndpointGetActionsStatusResponse, } from './endpoint/actions/status/status.gen'; +import type { GetEndpointExceptionsPerPolicyOptInResponse } from './endpoint/endpoint_exceptions_per_policy_opt_in/endpoint_exceptions_per_policy_opt_in.gen'; import type { GetEndpointMetadataListRequestQueryInput, GetEndpointMetadataListResponse, @@ -1872,6 +1873,18 @@ finalize it. }) .catch(catchAxiosErrorFormatAndThrow); } + async getEndpointExceptionsPerPolicyOptIn() { + this.log.info(`${new Date().toISOString()} Calling API GetEndpointExceptionsPerPolicyOptIn`); + return this.kbnClient + .request({ + path: '/internal/api/endpoint/endpoint_exceptions_per_policy_opt_in', + headers: { + [ELASTIC_HTTP_VERSION_HEADER]: '1', + }, + method: 'GET', + }) + .catch(catchAxiosErrorFormatAndThrow); + } async getEndpointMetadataList(props: GetEndpointMetadataListProps) { this.log.info(`${new Date().toISOString()} Calling API GetEndpointMetadataList`); return this.kbnClient @@ -2653,6 +2666,20 @@ The difference between the `id` and `rule_id` is that the `id` is a unique rule }) .catch(catchAxiosErrorFormatAndThrow); } + async performEndpointExceptionsPerPolicyOptIn() { + this.log.info( + `${new Date().toISOString()} Calling API PerformEndpointExceptionsPerPolicyOptIn` + ); + return this.kbnClient + .request({ + path: '/internal/api/endpoint/endpoint_exceptions_per_policy_opt_in', + headers: { + [ELASTIC_HTTP_VERSION_HEADER]: '1', + }, + method: 'POST', + }) + .catch(catchAxiosErrorFormatAndThrow); + } /** * Apply a bulk action, such as bulk edit, duplicate, or delete, to multiple detection rules. The bulk action is applied to all rules that match the query or to the rules listed by their IDs. diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/constants.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/constants.ts index e2b17a75ee88f..a58e658541942 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/constants.ts @@ -135,6 +135,9 @@ export const ENDPOINT_ERROR_CODES: Record = { export const ENDPOINT_FIELDS_SEARCH_STRATEGY = 'endpointFields'; export const ENDPOINT_SEARCH_STRATEGY = 'endpointSearchStrategy'; +/** Endpoint Exceptions routes */ +export const ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE = `${BASE_INTERNAL_ENDPOINT_ROUTE}/endpoint_exceptions_per_policy_opt_in`; + /** Search strategy keys */ export const ENDPOINT_PACKAGE_POLICIES_STATS_STRATEGY = 'endpointPackagePoliciesStatsStrategy'; diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/authz/authz.test.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/authz/authz.test.ts index 20ee48212a156..ad89f81ccbfdb 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/authz/authz.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/authz/authz.test.ts @@ -47,47 +47,47 @@ describe('Endpoint Authz service', () => { it('should set `canIsolateHost` to false if not proper license', () => { licenseService.isPlatinumPlus.mockReturnValue(false); - expect(calculateEndpointAuthz(licenseService, fleetAuthz, userRoles).canIsolateHost).toBe( - false - ); + expect( + calculateEndpointAuthz(licenseService, fleetAuthz, userRoles, false).canIsolateHost + ).toBe(false); }); it('should set `canKillProcess` to false if not proper license', () => { licenseService.isEnterprise.mockReturnValue(false); - expect(calculateEndpointAuthz(licenseService, fleetAuthz, userRoles).canKillProcess).toBe( - false - ); + expect( + calculateEndpointAuthz(licenseService, fleetAuthz, userRoles, false).canKillProcess + ).toBe(false); }); it('should set `canSuspendProcess` to false if not proper license', () => { licenseService.isEnterprise.mockReturnValue(false); - expect(calculateEndpointAuthz(licenseService, fleetAuthz, userRoles).canSuspendProcess).toBe( - false - ); + expect( + calculateEndpointAuthz(licenseService, fleetAuthz, userRoles, false).canSuspendProcess + ).toBe(false); }); it('should set `canGetRunningProcesses` to false if not proper license', () => { licenseService.isEnterprise.mockReturnValue(false); expect( - calculateEndpointAuthz(licenseService, fleetAuthz, userRoles).canGetRunningProcesses + calculateEndpointAuthz(licenseService, fleetAuthz, userRoles, false).canGetRunningProcesses ).toBe(false); }); it('should set `canUnIsolateHost` to true even if not proper license', () => { licenseService.isPlatinumPlus.mockReturnValue(false); - expect(calculateEndpointAuthz(licenseService, fleetAuthz, userRoles).canUnIsolateHost).toBe( - true - ); + expect( + calculateEndpointAuthz(licenseService, fleetAuthz, userRoles, false).canUnIsolateHost + ).toBe(true); }); it(`should allow Host Isolation Exception read/delete when license is not Platinum+`, () => { licenseService.isPlatinumPlus.mockReturnValue(false); - expect(calculateEndpointAuthz(licenseService, fleetAuthz, userRoles)).toEqual( + expect(calculateEndpointAuthz(licenseService, fleetAuthz, userRoles, false)).toEqual( expect.objectContaining({ canWriteHostIsolationExceptions: false, canAccessHostIsolationExceptions: false, @@ -101,36 +101,37 @@ describe('Endpoint Authz service', () => { [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 - ); + expect( + calculateEndpointAuthz(licenseService, fleetAuthz, userRoles, false).canAccessFleet + ).toBe(value); }); it(`should set canReadFleetAgents to ${value} if \`fleet.readAgents\` is ${value}`, () => { fleetAuthz.fleet.readAgents = value; expect( - calculateEndpointAuthz(licenseService, fleetAuthz, userRoles).canReadFleetAgents + calculateEndpointAuthz(licenseService, fleetAuthz, userRoles, false).canReadFleetAgents ).toBe(value); }); it(`should set canWriteFleetAgents to ${value} if \`fleet.allAgents\` is ${value}`, () => { fleetAuthz.fleet.allAgents = value; expect( - calculateEndpointAuthz(licenseService, fleetAuthz, userRoles).canWriteFleetAgents + calculateEndpointAuthz(licenseService, fleetAuthz, userRoles, false).canWriteFleetAgents ).toBe(value); }); it(`should set canReadFleetAgentPolicies to ${value} if \`fleet.readAgentPolicies\` is ${value}`, () => { fleetAuthz.fleet.readAgentPolicies = value; expect( - calculateEndpointAuthz(licenseService, fleetAuthz, userRoles).canReadFleetAgentPolicies + calculateEndpointAuthz(licenseService, fleetAuthz, userRoles, false) + .canReadFleetAgentPolicies ).toBe(value); }); it(`should set canWriteIntegrationPolicies to ${value} if \`integrations.writeIntegrationPolicies\` is ${value}`, () => { fleetAuthz.integrations.writeIntegrationPolicies = value; expect( - calculateEndpointAuthz(licenseService, fleetAuthz, userRoles) + calculateEndpointAuthz(licenseService, fleetAuthz, userRoles, false) .canWriteIntegrationPolicies ).toBe(value); }); @@ -140,14 +141,16 @@ describe('Endpoint Authz service', () => { it('should set canAccessEndpointManagement if not superuser', () => { userRoles = []; expect( - calculateEndpointAuthz(licenseService, fleetAuthz, userRoles).canAccessEndpointManagement + calculateEndpointAuthz(licenseService, fleetAuthz, userRoles, false) + .canAccessEndpointManagement ).toBe(false); }); it('should give canAccessEndpointManagement if superuser', () => { userRoles = ['superuser']; expect( - calculateEndpointAuthz(licenseService, fleetAuthz, userRoles).canAccessEndpointManagement + calculateEndpointAuthz(licenseService, fleetAuthz, userRoles, false) + .canAccessEndpointManagement ).toBe(true); userRoles = []; }); @@ -184,7 +187,7 @@ describe('Endpoint Authz service', () => { ['canWriteWorkflowInsights', 'writeWorkflowInsights'], ['canManageGlobalArtifacts', 'writeGlobalArtifacts'], ])('%s should be true if `packagePrivilege.%s` is `true`', (auth) => { - const authz = calculateEndpointAuthz(licenseService, fleetAuthz, userRoles); + const authz = calculateEndpointAuthz(licenseService, fleetAuthz, userRoles, false); expect(authz[auth]).toBe(true); }); @@ -192,7 +195,7 @@ describe('Endpoint Authz service', () => { ['canReadEndpointExceptions', 'showEndpointExceptions'], ['canWriteEndpointExceptions', 'crudEndpointExceptions'], ])('%s should be true if `endpointExceptionsPrivileges.%s` is `true`', (auth) => { - const authz = calculateEndpointAuthz(licenseService, fleetAuthz, userRoles); + const authz = calculateEndpointAuthz(licenseService, fleetAuthz, userRoles, false); expect(authz[auth]).toBe(true); }); @@ -235,7 +238,7 @@ describe('Endpoint Authz service', () => { fleetAuthz.packagePrivileges!.endpoint.actions[privilege].executePackageAction = false; }); - const authz = calculateEndpointAuthz(licenseService, fleetAuthz, userRoles); + const authz = calculateEndpointAuthz(licenseService, fleetAuthz, userRoles, false); expect(authz[auth]).toBe(false); }); @@ -248,7 +251,7 @@ describe('Endpoint Authz service', () => { fleetAuthz.endpointExceptionsPrivileges!.actions[privilege] = false; }); - const authz = calculateEndpointAuthz(licenseService, fleetAuthz, userRoles); + const authz = calculateEndpointAuthz(licenseService, fleetAuthz, userRoles, false); expect(authz[auth]).toBe(false); }); @@ -291,7 +294,7 @@ describe('Endpoint Authz service', () => { privileges.forEach((privilege) => { fleetAuthz.packagePrivileges!.endpoint.actions[privilege].executePackageAction = false; }); - const authz = calculateEndpointAuthz(licenseService, fleetAuthz, undefined); + const authz = calculateEndpointAuthz(licenseService, fleetAuthz, undefined, false); expect(authz[auth]).toBe(false); } ); @@ -308,7 +311,7 @@ describe('Endpoint Authz service', () => { responseConsolePrivilege ].executePackageAction = true; - const authz = calculateEndpointAuthz(licenseService, fleetAuthz, userRoles); + const authz = calculateEndpointAuthz(licenseService, fleetAuthz, userRoles, false); // Having ONLY host isolation Release response action can only be true in a // downgrade scenario, where we allow the user to continue to release isolated @@ -321,22 +324,24 @@ describe('Endpoint Authz service', () => { } ); - it.each` - privilege | expectedResult | roles | description - ${'canReadAdminData'} | ${true} | ${['superuser', 'role-2']} | ${'user has superuser role'} - ${'canWriteAdminData'} | ${true} | ${['superuser', 'role-2']} | ${'user has superuser role'} - ${'canReadAdminData'} | ${false} | ${['role-2']} | ${'user does NOT have superuser role'} - ${'canWriteAdminData'} | ${false} | ${['role-2']} | ${'user does NOT superuser role'} - `( - 'should set `$privilege` to `$expectedResult` when $description', - ({ privilege, expectedResult, roles }) => { - expect( - calculateEndpointAuthz(licenseService, fleetAuthz, roles)[ - privilege as keyof EndpointAuthz - ] - ).toEqual(expectedResult); - } - ); + describe.each(['canReadAdminData', 'canWriteAdminData'])('%s', (privilege) => { + it.each` + isServerless | expectedResult | roles | description + ${false} | ${true} | ${['superuser', 'role-2']} | ${'user has superuser role on ESS'} + ${false} | ${false} | ${['role-2', 'admin']} | ${'user does NOT have superuser role on ESS'} + ${true} | ${true} | ${['admin', 'role-2']} | ${'user has admin role on Serverless'} + ${true} | ${false} | ${['role-2', 'superuser']} | ${'user does NOT have admin role on Serverless'} + `( + `should set '${privilege}' to '$expectedResult' when $description`, + ({ expectedResult, roles, isServerless }) => { + expect( + calculateEndpointAuthz(licenseService, fleetAuthz, roles, isServerless)[ + privilege as keyof EndpointAuthz + ] + ).toEqual(expectedResult); + } + ); + }); }); describe('getEndpointAuthzInitialState()', () => { diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/authz/authz.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/authz/authz.ts index b17b00ff8c61f..36799b4fb0a4c 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/authz/authz.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/authz/authz.ts @@ -63,10 +63,14 @@ export const calculateEndpointAuthz = ( licenseService: LicenseService, fleetAuthz: FleetAuthz, userRoles: MaybeImmutable = [], + isServerless: boolean, productFeaturesService?: ProductFeaturesService // only exists on the server side ): EndpointAuthz => { const hasAuth = hasAuthFactory(fleetAuthz, productFeaturesService); const hasSuperuserRole = userRoles.includes('superuser'); + const hasAdminRole = userRoles.includes('admin'); + + const hasSuperuserPrivileges = isServerless ? hasAdminRole : hasSuperuserRole; const isPlatinumPlusLicense = licenseService.isPlatinumPlus(); const isEnterpriseLicense = licenseService.isEnterprise(); @@ -111,9 +115,9 @@ export const calculateEndpointAuthz = ( const canReadScriptsLibrary = hasAuth('readScriptsManagement'); const canWriteScriptsLibrary = hasAuth('writeScriptsManagement'); - // These are currently tied to the superuser role - const canReadAdminData = hasSuperuserRole; - const canWriteAdminData = hasSuperuserRole; + // These are currently tied to the superuser role on ESS and the admin role on Serverless + const canReadAdminData = hasSuperuserPrivileges; + const canWriteAdminData = hasSuperuserPrivileges; const authz: EndpointAuthz = { canWriteSecuritySolution, diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/authz.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/authz.ts index 6090b2f5ae2fa..1ec12b4c9b8d9 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/authz.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/authz.ts @@ -99,6 +99,7 @@ export interface EndpointAuthz { canReadEndpointExceptions: boolean; /** if the user has read permissions for endpoint exceptions */ canWriteEndpointExceptions: boolean; + /** If user is allowed to manage global artifacts. Introduced support for spaces feature */ canManageGlobalArtifacts: boolean; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/user_privileges/endpoint/use_endpoint_privileges.test.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/user_privileges/endpoint/use_endpoint_privileges.test.ts index c53306ae807b1..efc9a60ed724d 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/user_privileges/endpoint/use_endpoint_privileges.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/user_privileges/endpoint/use_endpoint_privileges.test.ts @@ -13,7 +13,7 @@ import type { AuthenticatedUser } from '@kbn/security-plugin/common'; import { createFleetAuthzMock } from '@kbn/fleet-plugin/common/mocks'; import type { EndpointPrivileges } from '../../../../../common/endpoint/types'; -import { useCurrentUser, useKibana } from '../../../lib/kibana'; +import { KibanaServices, useCurrentUser, useKibana } from '../../../lib/kibana'; import { licenseService } from '../../../hooks/use_license'; import { useEndpointPrivileges } from './use_endpoint_privileges'; import { getEndpointPrivilegesInitialStateMock } from './mocks'; @@ -36,6 +36,7 @@ jest.mock('../../../hooks/use_license', () => { const useKibanaMock = useKibana as jest.Mocked; const licenseServiceMock = licenseService as jest.Mocked; +const KibanaServicesMock = KibanaServices as jest.Mocked; describe('When using useEndpointPrivileges hook', () => { let authenticatedUser: AuthenticatedUser; @@ -59,6 +60,7 @@ describe('When using useEndpointPrivileges hook', () => { show: true, }, }; + KibanaServicesMock.getBuildFlavor.mockReturnValue('traditional'); licenseServiceMock.isPlatinumPlus.mockReturnValue(true); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/user_privileges/endpoint/use_endpoint_privileges.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/user_privileges/endpoint/use_endpoint_privileges.ts index c98e462eb540b..bbe7ae2d6cf51 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/user_privileges/endpoint/use_endpoint_privileges.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/user_privileges/endpoint/use_endpoint_privileges.ts @@ -8,7 +8,7 @@ import { useEffect, useMemo, useState } from 'react'; import { isEmpty } from 'lodash'; import { useIsMounted } from '@kbn/securitysolution-hook-utils'; -import { useCurrentUser, useKibana } from '../../../lib/kibana'; +import { KibanaServices, useCurrentUser, useKibana } from '../../../lib/kibana'; import { useLicense } from '../../../hooks/use_license'; import type { EndpointPrivileges, @@ -32,6 +32,7 @@ export const useEndpointPrivileges = (): Immutable => { const user = useCurrentUser(); const kibanaServices = useKibana().services; + const fleetServicesFromUseKibana = kibanaServices.fleet; // The `fleetServicesFromPluginStart` will be defined when this hooks called from a component // that is being rendered under the Fleet context (UI extensions). The `fleetServicesFromUseKibana` @@ -44,17 +45,19 @@ export const useEndpointPrivileges = (): Immutable => { const [userRolesCheckDone, setUserRolesCheckDone] = useState(false); const [userRoles, setUserRoles] = useState>([]); + const isServerless = KibanaServices.getBuildFlavor() === 'serverless'; + const privileges = useMemo(() => { const loading = !userRolesCheckDone || !user; const privilegeList: EndpointPrivileges = Object.freeze({ loading, ...(!loading && fleetAuthz && !isEmpty(user) - ? calculateEndpointAuthz(licenseService, fleetAuthz, userRoles) + ? calculateEndpointAuthz(licenseService, fleetAuthz, userRoles, isServerless) : getEndpointAuthzInitialState()), }); return privilegeList; - }, [userRolesCheckDone, user, fleetAuthz, licenseService, userRoles]); + }, [userRolesCheckDone, user, fleetAuthz, licenseService, userRoles, isServerless]); // get user roles useEffect(() => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts b/x-pack/solutions/security/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts index 637961233a206..f16cb5f7cf1ca 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts @@ -16,7 +16,6 @@ import { coreMock, themeServiceMock } from '@kbn/core/public/mocks'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; import { securityMock } from '@kbn/security-plugin/public/mocks'; -import { Storage } from '@kbn/kibana-utils-plugin/public'; import { DEFAULT_APP_REFRESH_INTERVAL, @@ -113,6 +112,7 @@ export const createStartServicesMock = ( core.uiSettings.get.mockImplementation(createUseUiSettingMock()); core.settings.client.get.mockImplementation(createUseUiSettingMock()); const { storage } = createSecuritySolutionStorageMock(); + const { storage: sessionStorage } = createSecuritySolutionStorageMock(); const apm = mockApm(); const data = dataPluginMock.createStartContract(); const customDataService = dataPluginMock.createStartContract(); @@ -277,12 +277,7 @@ export const createStartServicesMock = ( timelineDataService, alerting, siemMigrations, - sessionStorage: new Storage({ - getItem: jest.fn(), - setItem: jest.fn(), - removeItem: jest.fn(), - clear: jest.fn(), - }), + sessionStorage, plugins: { onStart: jest.fn() }, } as unknown as StartServices; }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_about_rule/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_about_rule/index.test.tsx index 40d693e9fb862..46af6cd5100d6 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_about_rule/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_about_rule/index.test.tsx @@ -37,13 +37,13 @@ import { } from '../../../rule_creation/components/alert_suppression_edit'; import { THRESHOLD_ALERT_SUPPRESSION_ENABLED } from '../../../rule_creation/components/threshold_alert_suppression_edit'; import { AlertSuppressionMissingFieldsStrategyEnum } from '../../../../../common/api/detection_engine'; -import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; +import { useGetEndpointExceptionsPerPolicyOptIn } from '../../../../management/hooks/artifacts/use_endpoint_per_policy_opt_in'; jest.mock('../../../../common/lib/kibana'); jest.mock('../../../../common/containers/source'); jest.mock('../../../../common/components/ml/hooks/use_get_jobs'); jest.mock('../../../../common/components/ml_popover/hooks/use_security_jobs'); -jest.mock('../../../../common/hooks/use_experimental_features'); +jest.mock('../../../../management/hooks/artifacts/use_endpoint_per_policy_opt_in'); jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { @@ -56,9 +56,8 @@ jest.mock('@elastic/eui', () => { }; }); const mockedUseKibana = mockUseKibana(); -(useIsExperimentalFeatureEnabled as jest.Mock).mockImplementation((param) => { - return param === 'endpointExceptionsMovedUnderManagement'; -}); +const mockedUseGetEndpointExceptionsPerPolicyOptIn = + useGetEndpointExceptionsPerPolicyOptIn as jest.Mock; export const stepDefineStepMLRule: DefineStepRule = { ruleType: 'machine_learning', @@ -135,6 +134,10 @@ describe.skip('StepAboutRuleComponent', () => { jobs: [], })); useSecurityJobsMock = (useSecurityJobs as jest.Mock).mockImplementation(() => ({ jobs: [] })); + + mockedUseGetEndpointExceptionsPerPolicyOptIn.mockImplementation(() => ({ + data: { status: false }, + })); }); it('it renders StepRuleDescription if isReadOnlyView is true and "name" property exists', () => { @@ -149,7 +152,28 @@ describe.skip('StepAboutRuleComponent', () => { expect(wrapper.find(StepRuleDescription).exists()).toBeTruthy(); }); - it('only shows endpoint exceptions for rule definition if feature flag enabled', async () => { + it('shows endpoint exceptions for rule definition if they are not per-policy', async () => { + mockedUseGetEndpointExceptionsPerPolicyOptIn.mockImplementation(() => ({ + data: { status: false }, + })); + + const wrapper = mount( {}} />, { + wrappingComponent: TestProviders as EnzymeComponentType<{}>, + }); + await act(async () => { + expect( + wrapper + .find('[data-test-subj="detectionEngineStepAboutRuleAssociatedToEndpointList"]') + .exists() + ).toBeTruthy(); + }); + }); + + it('does not show endpoint exceptions for rule definition if they are per-policy', async () => { + mockedUseGetEndpointExceptionsPerPolicyOptIn.mockImplementation(() => ({ + data: { status: true }, + })); + const wrapper = mount( {}} />, { wrappingComponent: TestProviders as EnzymeComponentType<{}>, }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_about_rule/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_about_rule/index.tsx index bbb43b0610c84..621e150e01517 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_about_rule/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_about_rule/index.tsx @@ -13,6 +13,7 @@ import styled from 'styled-components'; import type { DataViewBase } from '@kbn/es-query'; import type { Severity, Type } from '@kbn/securitysolution-io-ts-alerting-types'; +import { useGetEndpointExceptionsPerPolicyOptIn } from '../../../../management/hooks/artifacts/use_endpoint_per_policy_opt_in'; import { defaultRiskScoreBySeverity } from '../../../../../common/detection_engine/constants'; import type { RuleSource } from '../../../../../common/api/detection_engine'; import { isEsqlRule, isThreatMatchRule } from '../../../../../common/detection_engine/utils'; @@ -40,7 +41,6 @@ import { MultiSelectFieldsAutocomplete } from '../multi_select_fields'; import { useAllEsqlRuleFields } from '../../hooks'; import { MaxSignals } from '../max_signals'; import { ThreatMatchIndicatorPathEdit } from '../../../rule_creation/components/threat_match_indicator_path_edit'; -import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; const CommonUseField = getUseField({ component: Field }); @@ -103,9 +103,7 @@ const StepAboutRuleComponent: FC = ({ const [indexPattern, setIndexPattern] = useState(indexIndexPattern); - const endpointExceptionsMovedUnderManagement = useIsExperimentalFeatureEnabled( - 'endpointExceptionsMovedUnderManagement' - ); + const { data: endpointPerPolicyOptIn } = useGetEndpointExceptionsPerPolicyOptIn(); useEffect(() => { if (index != null && (dataViewId === '' || dataViewId == null)) { @@ -338,7 +336,9 @@ const StepAboutRuleComponent: FC = ({ /> - {!endpointExceptionsMovedUnderManagement ? ( + {endpointPerPolicyOptIn?.status === true ? ( + + ) : ( = ({ }} /> - ) : ( - )} - {isEndpointExceptionsMovedFFEnabled && ( + {shouldShowEndpointExceptionsCannotBeAddedToRuleCallout && ( = ({ rule }) => { [rule] ); - // TODO: switch to per-policy use opt-in state in follow-up (https://github.com/elastic/security-team/issues/14870) - const isEndpointExceptionsMovedFFEnabled = useIsExperimentalFeatureEnabled( - 'endpointExceptionsMovedUnderManagement' - ); + const { data: endpointPerPolicyOptIn } = useGetEndpointExceptionsPerPolicyOptIn(); + const shouldShowEndpointExceptionsCannotBeAddedToRuleCallout = + endpointPerPolicyOptIn?.status === true && endpointPerPolicyOptIn.reason === 'userOptedIn'; if ( redirectToDetections( @@ -579,20 +578,22 @@ const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => { - {isEndpointExceptionsMovedFFEnabled && isEndpointExceptionListLinked && ( - - )} - {isEndpointExceptionsMovedFFEnabled && !isEndpointExceptionListLinked && ( - - )} + {shouldShowEndpointExceptionsCannotBeAddedToRuleCallout && + isEndpointExceptionListLinked && ( + + )} + {shouldShowEndpointExceptionsCannotBeAddedToRuleCallout && + !isEndpointExceptionListLinked && ( + + )} { const r = jest.requireActual('react'); return { ...r, useReducer: jest.fn() }; }); const mockUseIsExperimentalFeatureEnabled = useIsExperimentalFeatureEnabled as jest.Mock; +const mockUseGetEndpointExceptionsPerPolicyOptIn = + useGetEndpointExceptionsPerPolicyOptIn as jest.Mock; const mockUseEndpointExceptionsCapability = useEndpointExceptionsCapability as jest.Mock; const sampleExceptionItem = { @@ -91,6 +96,7 @@ describe('ExceptionsViewer', () => { mockUseEndpointExceptionsCapability.mockReturnValue(true); mockUseIsExperimentalFeatureEnabled.mockReturnValue(false); + mockUseGetEndpointExceptionsPerPolicyOptIn.mockReturnValue({ data: { status: false } }); (fetchExceptionListsItemsByListIds as jest.Mock).mockReturnValue({ total: 0 }); @@ -416,6 +422,26 @@ describe('ExceptionsViewer', () => { }); describe('when Endpoint exception is moved under management FF is enabled', () => { + let render: () => ReturnType; + + const expectCalloutToBeRendered = ({ + wrapper, + shouldBeDismissible, + }: { + wrapper: ReturnType; + shouldBeDismissible: boolean; + }) => { + const callout = wrapper.find('[data-test-subj="EndpointExceptionsMovedCallout"]'); + + expect(callout.exists()).toBeTruthy(); + + if (shouldBeDismissible) { + expect(callout.first().prop('onDismiss')).toBeTruthy(); + } else { + expect(callout.first().prop('onDismiss')).toBeFalsy(); + } + }; + beforeEach(() => { mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); @@ -434,6 +460,10 @@ describe('ExceptionsViewer', () => { }); it('should not render EndpointExceptionsMovedCallout when rule is not an endpoint security rule and does not have endpoint exceptions', () => { + mockUseGetEndpointExceptionsPerPolicyOptIn.mockReturnValue({ + data: { status: true, reason: 'userOptedIn' } as OptInStatusMetadata, + }); + const wrapper = mount( { ).toBeFalsy(); }); - it('should render non-dismissible EndpointExceptionsMovedCallout when rule is an endpoint security rule', () => { - const wrapper = mount( - - - - ); - - expect( - wrapper.find('[data-test-subj="EndpointExceptionsMovedCallout"]').exists() - ).toBeTruthy(); - expect( - wrapper.find('[data-test-subj="EndpointExceptionsMovedCallout"]').first().prop('onDismiss') - ).toBeFalsy(); + describe('when the rule is an Endpoint security rule', () => { + beforeEach(() => { + render = () => + mount( + + + + ); + }); + + it('should render non-dismissible EndpointExceptionsMovedCallout when user has NOT opted in to per-policy Endpoint exceptions', () => { + mockUseGetEndpointExceptionsPerPolicyOptIn.mockReturnValue({ + data: { status: false } as OptInStatusMetadata, + }); + + const wrapper = render(); + + expectCalloutToBeRendered({ wrapper, shouldBeDismissible: false }); + }); + + it('should render non-dismissible EndpointExceptionsMovedCallout when user has opted in to per-policy Endpoint exceptions', () => { + mockUseGetEndpointExceptionsPerPolicyOptIn.mockReturnValue({ + data: { status: true, reason: 'userOptedIn' } as OptInStatusMetadata, + }); + + const wrapper = render(); + + expectCalloutToBeRendered({ wrapper, shouldBeDismissible: false }); + }); + + it('should NOT render EndpointExceptionsMovedCallout on a new deployment', () => { + mockUseGetEndpointExceptionsPerPolicyOptIn.mockReturnValue({ + data: { status: true, reason: 'newDeployment' } as OptInStatusMetadata, + }); + + const wrapper = render(); + + expect( + wrapper.find('[data-test-subj="EndpointExceptionsMovedCallout"]').exists() + ).toBeFalsy(); + }); }); - it('should render dismissible EndpointExceptionsMovedCallout when rule has endpoint_list linked', () => { - const wrapper = mount( - - - - ); - - expect( - wrapper.find('[data-test-subj="EndpointExceptionsMovedCallout"]').exists() - ).toBeTruthy(); - expect( - wrapper.find('[data-test-subj="EndpointExceptionsMovedCallout"]').first().prop('onDismiss') - ).toBeTruthy(); + describe('when a detection rule has endpoint_list linked', () => { + beforeEach(() => { + render = () => + mount( + + + + ); + }); + + it('should render non-dismissible EndpointExceptionsMovedCallout when user has NOT opted in to per-policy Endpoint exceptions', () => { + mockUseGetEndpointExceptionsPerPolicyOptIn.mockReturnValue({ + data: { status: false } as OptInStatusMetadata, + }); + + const wrapper = render(); + + expectCalloutToBeRendered({ wrapper, shouldBeDismissible: false }); + }); + + it('should render dismissible EndpointExceptionsMovedCallout when user has opted in to per-policy Endpoint exceptions', () => { + mockUseGetEndpointExceptionsPerPolicyOptIn.mockReturnValue({ + data: { status: true, reason: 'userOptedIn' } as OptInStatusMetadata, + }); + + const wrapper = render(); + + expectCalloutToBeRendered({ wrapper, shouldBeDismissible: true }); + }); + + it('should NOT render EndpointExceptionsMovedCallout on a new deployment', () => { + mockUseGetEndpointExceptionsPerPolicyOptIn.mockReturnValue({ + data: { status: true, reason: 'newDeployment' } as OptInStatusMetadata, + }); + + const wrapper = render(); + + expect( + wrapper.find('[data-test-subj="EndpointExceptionsMovedCallout"]').exists() + ).toBeFalsy(); + }); }); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.tsx index 862aac4144303..2da743f43bbb1 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.tsx @@ -30,6 +30,7 @@ import { getSavedObjectTypes, } from '@kbn/securitysolution-list-utils'; import { ENDPOINT_ARTIFACT_LISTS } from '@kbn/securitysolution-list-constants'; +import { useGetEndpointExceptionsPerPolicyOptIn } from '../../../../management/hooks/artifacts/use_endpoint_per_policy_opt_in'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { EndpointExceptionsMovedCallout } from '../../../../exceptions/components/endpoint_exceptions_moved_callout'; import { useEndpointExceptionsCapability } from '../../../../exceptions/hooks/use_endpoint_exceptions_capability'; @@ -505,18 +506,53 @@ const ExceptionsViewerComponent = ({ const isEndpointExceptionsMovedFFEnabled = useIsExperimentalFeatureEnabled( 'endpointExceptionsMovedUnderManagement' ); - // TODO: switch to per-policy use opt-in state in follow-up (https://github.com/elastic/security-team/issues/14870) - const hasUserOptedInForPerPolicyUse = true; + const { data: endpointPerPolicyOptIn } = useGetEndpointExceptionsPerPolicyOptIn(); - const showEndpointExceptionsMovedCallout = - isEndpointExceptionsMovedFFEnabled && - (isEndpointSecurityRule || - (isDetectionRuleWithEndpointExceptions && !hasUserOptedInForPerPolicyUse)); + const endpointExceptionsMovedCallout = useMemo(() => { + if (!isEndpointExceptionsMovedFFEnabled) { + return null; + } - const showEndpointExceptionNoLongerEvaluatedCallout = - isEndpointExceptionsMovedFFEnabled && - isDetectionRuleWithEndpointExceptions && - hasUserOptedInForPerPolicyUse; + if ( + isEndpointSecurityRule && + (endpointPerPolicyOptIn?.status === false || endpointPerPolicyOptIn?.reason === 'userOptedIn') + ) { + return ( + + ); + } + + if (isDetectionRuleWithEndpointExceptions) { + if (endpointPerPolicyOptIn?.status === false) { + return ( + + ); + } else if (endpointPerPolicyOptIn?.reason === 'userOptedIn') { + return ( + + ); + } + } + + return null; + }, [ + isEndpointExceptionsMovedFFEnabled, + isEndpointSecurityRule, + endpointPerPolicyOptIn, + isDetectionRuleWithEndpointExceptions, + ]); return ( <> @@ -549,20 +585,7 @@ const ExceptionsViewerComponent = ({ <> - {showEndpointExceptionsMovedCallout && ( - - )} - {showEndpointExceptionNoLongerEvaluatedCallout && ( - - )} + {endpointExceptionsMovedCallout} {isEndpointSpecified ? i18n.ENDPOINT_EXCEPTIONS_TAB_ABOUT : i18n.EXCEPTIONS_TAB_ABOUT} diff --git a/x-pack/solutions/security/plugins/security_solution/public/exceptions/pages/shared_lists/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/exceptions/pages/shared_lists/index.tsx index ee0797efd5e8d..c2d9b5df12f72 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/exceptions/pages/shared_lists/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/exceptions/pages/shared_lists/index.tsx @@ -31,6 +31,7 @@ import { EmptyViewerState, ViewerStatus } from '@kbn/securitysolution-exception- import { ProjectRoutingAccess, useRouteBasedCpsPickerAccess } from '@kbn/cps-utils'; import { ENDPOINT_ARTIFACT_LISTS } from '@kbn/securitysolution-list-constants'; +import { useGetEndpointExceptionsPerPolicyOptIn } from '../../../management/hooks/artifacts/use_endpoint_per_policy_opt_in'; import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; import { AutoDownload } from '../../../common/components/auto_download/auto_download'; import { useKibana } from '../../../common/lib/kibana'; @@ -98,6 +99,7 @@ export const SharedLists = React.memo(() => { const isEndpointExceptionsMovedFFEnabled = useIsExperimentalFeatureEnabled( 'endpointExceptionsMovedUnderManagement' ); + const { data: endpointPerPolicyOptIn } = useGetEndpointExceptionsPerPolicyOptIn(); const canAccessEndpointExceptions = useEndpointExceptionsCapability('showEndpointExceptions'); const canWriteEndpointExceptions = useEndpointExceptionsCapability('crudEndpointExceptions'); @@ -604,9 +606,11 @@ export const SharedLists = React.memo(() => {
- {isEndpointExceptionsMovedFFEnabled && ( - - )} + {isEndpointExceptionsMovedFFEnabled && + (endpointPerPolicyOptIn?.status === false || + endpointPerPolicyOptIn?.reason === 'userOptedIn') && ( + + )} {!initLoading && } diff --git a/x-pack/solutions/security/plugins/security_solution/public/exceptions/pages/shared_lists/shared_lists.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/exceptions/pages/shared_lists/shared_lists.test.tsx index f22c6a1567241..e8e0faa1f4ebb 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/exceptions/pages/shared_lists/shared_lists.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/exceptions/pages/shared_lists/shared_lists.test.tsx @@ -22,6 +22,8 @@ import { useEndpointExceptionsCapability } from '../../hooks/use_endpoint_except import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; import { ENDPOINT_ARTIFACT_LIST_IDS } from '@kbn/securitysolution-list-constants'; import { ExceptionListTypeEnum } from '@kbn/securitysolution-exceptions-common/api'; +import { useGetEndpointExceptionsPerPolicyOptIn } from '../../../management/hooks/artifacts/use_endpoint_per_policy_opt_in'; +import type { OptInStatusMetadata } from '../../../../server/endpoint/lib/reference_data'; jest.mock('../../../common/components/user_privileges'); jest.mock('../../../common/utils/route/mocks'); @@ -72,7 +74,10 @@ jest.mock('../../components/create_shared_exception_list', () => ({ jest.mock('../../../common/hooks/use_experimental_features', () => ({ useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(false), })); +jest.mock('../../../management/hooks/artifacts/use_endpoint_per_policy_opt_in'); const mockUseIsExperimentalFeatureEnabled = useIsExperimentalFeatureEnabled as jest.Mock; +const mockUseGetEndpointExceptionsPerPolicyOptIn = + useGetEndpointExceptionsPerPolicyOptIn as jest.Mock; describe('SharedLists', () => { const mockHistory = generateHistoryMock(); @@ -119,6 +124,7 @@ describe('SharedLists', () => { (useEndpointExceptionsCapability as jest.Mock).mockReturnValue(true); mockUseIsExperimentalFeatureEnabled.mockReturnValue(false); + mockUseGetEndpointExceptionsPerPolicyOptIn.mockReturnValue({ data: { status: false } }); }); it('renders empty view if no lists exist', async () => { @@ -250,8 +256,30 @@ describe('SharedLists', () => { ); }); - it('should display dismissible callout when FF is enabled', () => { + it('should display dismissible callout when FF is enabled but user has not opted in to per-policy yet', () => { mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); + mockUseGetEndpointExceptionsPerPolicyOptIn.mockReturnValue({ + data: { status: false } as OptInStatusMetadata, + }); + + const { getByTestId } = render( + + + + ); + + const callout = getByTestId('EndpointExceptionsMovedCallout'); + expect(callout).toBeInTheDocument(); + expect(callout).toHaveTextContent('Endpoint exceptions have moved.'); + + expect(getByTestId('euiDismissCalloutButton')).toBeTruthy(); + }); + + it('should display dismissible callout when FF is enabled and user has opted in to per-policy', () => { + mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); + mockUseGetEndpointExceptionsPerPolicyOptIn.mockReturnValue({ + data: { status: true, reason: 'userOptedIn' } as OptInStatusMetadata, + }); const { getByTestId } = render( @@ -266,6 +294,22 @@ describe('SharedLists', () => { expect(getByTestId('euiDismissCalloutButton')).toBeTruthy(); }); + it('should NOT display dismissible callout when FF is enabled on a new deployment', () => { + mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); + mockUseGetEndpointExceptionsPerPolicyOptIn.mockReturnValue({ + data: { status: true, reason: 'newDeployment' } as OptInStatusMetadata, + }); + + const { queryByTestId } = render( + + + + ); + + const callout = queryByTestId('EndpointExceptionsMovedCallout'); + expect(callout).not.toBeInTheDocument(); + }); + it('should fetch "endpoint_list" but hide other endpoint artifacts when Endpoint exceptions moved FF is disabled', async () => { mockUseIsExperimentalFeatureEnabled.mockReturnValue(false); (useUserPrivileges as jest.Mock).mockReturnValue({ diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/administration_list_page.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/components/administration_list_page.tsx index 46ce68f8db4a2..5c60d259a23e0 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/components/administration_list_page.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/administration_list_page.tsx @@ -75,7 +75,7 @@ export const AdministrationListPage = memo< restrictWidth={restrictWidth} data-test-subj={getTestId('header')} /> - + )} diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.tsx index 84a759604f8fd..2a915f5a7b5ff 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.tsx @@ -14,6 +14,7 @@ import { useLocation } from 'react-router-dom'; import { useIsMounted } from '@kbn/securitysolution-hook-utils'; import { HeaderMenu } from '@kbn/securitysolution-exception-list-components'; import { useApi } from '@kbn/securitysolution-list-hooks'; +import type { Action } from '@kbn/securitysolution-exception-list-components'; import { AutoDownload } from '../../../common/components/auto_download/auto_download'; import type { ServerApiError } from '../../../common/types'; import { AdministrationListPage } from '../administration_list_page'; @@ -80,7 +81,9 @@ export interface ArtifactListPageProps { allowCardDeleteAction?: boolean; allowCardCreateAction?: boolean; secondaryPageInfo?: React.ReactNode; + callout?: React.ReactNode; CardDecorator?: React.ComponentType; + additionalActions?: Action[]; } export const ArtifactListPage = memo( @@ -90,6 +93,7 @@ export const ArtifactListPage = memo( searchableFields = DEFAULT_EXCEPTION_LIST_ITEM_SEARCHABLE_FIELDS, labels: _labels = {}, secondaryPageInfo, + callout, onFormSubmit, flyoutSize, 'data-test-subj': dataTestSubj, @@ -97,6 +101,7 @@ export const ArtifactListPage = memo( allowCardCreateAction = true, allowCardDeleteAction = true, CardDecorator, + additionalActions, }) => { const areEndpointExceptionsMovedUnderManagementFFEnabled = useIsExperimentalFeatureEnabled( 'endpointExceptionsMovedUnderManagement' @@ -311,6 +316,39 @@ export const ArtifactListPage = memo( ); }, [labels.pageAboutInfo, secondaryPageInfo]); + const actionsToDisplay: Action[] = useMemo( + () => [ + ...(areEndpointExceptionsMovedUnderManagementFFEnabled + ? [ + { + key: 'ImportButton', + icon: 'download', + label: labels.pageImportButtonTitle, + onClick: handleImport, + disabled: !allowCardCreateAction, + }, + { + key: 'ExportButton', + icon: 'upload', + label: labels.pageExportButtonTitle, + onClick: handleExport, + }, + ] + : []), + + ...(additionalActions ?? []), + ], + [ + additionalActions, + allowCardCreateAction, + areEndpointExceptionsMovedUnderManagementFFEnabled, + handleExport, + handleImport, + labels.pageExportButtonTitle, + labels.pageImportButtonTitle, + ] + ); + if (isPageInitializing) { return ; } @@ -335,25 +373,11 @@ export const ArtifactListPage = memo( )} - {areEndpointExceptionsMovedUnderManagementFFEnabled && ( + {actionsToDisplay.length > 0 && ( )} @@ -417,6 +441,9 @@ export const ArtifactListPage = memo( /> ) : ( <> + {callout} + + , dataTestSubj: string = 'testPage' ) => { - const getMenuButton = () => - renderResult.getByTestId(`${dataTestSubj}-exportImportMenuButtonIcon`); + const getMenuButton = () => renderResult.getByTestId(`${dataTestSubj}-overflowMenuButtonIcon`); const queryMenuButton = () => - renderResult.queryByTestId(`${dataTestSubj}-exportImportMenuButtonIcon`); + renderResult.queryByTestId(`${dataTestSubj}-overflowMenuButtonIcon`); const getExportButton = () => - renderResult.getByTestId(`${dataTestSubj}-exportImportMenuActionItemExportButton`); + renderResult.getByTestId(`${dataTestSubj}-overflowMenuActionItemExportButton`); const getImportButton = () => - renderResult.getByTestId(`${dataTestSubj}-exportImportMenuActionItemImportButton`); + renderResult.getByTestId(`${dataTestSubj}-overflowMenuActionItemImportButton`); return { getExportButton, getImportButton, getMenuButton, queryMenuButton }; }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/artifacts/endpoint_exceptions.cy.ts b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/artifacts/endpoint_exceptions.cy.ts index 028d796ff5d94..4fe8240442567 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/artifacts/endpoint_exceptions.cy.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/artifacts/endpoint_exceptions.cy.ts @@ -6,16 +6,27 @@ */ import * as essSecurityHeaders from '@kbn/test-suites-xpack-security/security_solution_cypress/cypress/screens/security_header'; import * as serverlessSecurityHeaders from '@kbn/test-suites-xpack-security/security_solution_cypress/cypress/screens/serverless_security_header'; +import { ENDPOINT_ARTIFACT_LISTS } from '@kbn/securitysolution-list-constants'; +import { ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE } from '../../../../../common/endpoint/constants'; import { + APP_ENDPOINT_EXCEPTIONS_PATH, APP_MANAGE_PATH, APP_PATH, RULES_FEATURE_ID, SECURITY_FEATURE_ID, } from '../../../../../common/constants'; import { login, ROLE } from '../../tasks/login'; +import { + createArtifactList, + createPerPolicyArtifact, + fetchEndpointExceptionPerPolicyOptInStatus, + resetEndpointExceptionPerPolicyOptInStatus, + removeAllArtifacts, +} from '../../tasks/artifacts'; +import { getArtifactsListTestDataForArtifact } from '../../fixtures/artifacts_page'; describe( - 'Endpoint exceptions - from Security Management/Assets', + 'Endpoint exceptions - under Security Management/Assets', { env: { ftrConfig: { @@ -29,66 +40,110 @@ describe( }, () => { - describe('ESS', { tags: ['@ess'] }, () => { - const loginWithReadAccess = () => { - login.withCustomKibanaPrivileges({ - [SECURITY_FEATURE_ID]: ['read', 'endpoint_exceptions_read'], - [RULES_FEATURE_ID]: ['read'], + describe('Navigation and access control', () => { + describe('ESS', { tags: ['@ess'] }, () => { + const loginWithReadAccess = () => { + login.withCustomKibanaPrivileges({ + [SECURITY_FEATURE_ID]: ['read', 'endpoint_exceptions_read'], + [RULES_FEATURE_ID]: ['read'], + }); + }; + + it('should display Endpoint Exceptions in Administration page', () => { + loginWithReadAccess(); + + cy.visit(APP_MANAGE_PATH); + cy.getByTestSubj('pageContainer').contains('Endpoint exceptions'); }); - }; - it('should display Endpoint Exceptions in Administration page', () => { - loginWithReadAccess(); + it('should be able to navigate to Endpoint Exceptions from Administration page', () => { + loginWithReadAccess(); + cy.visit(APP_MANAGE_PATH); + cy.getByTestSubj('pageContainer').contains('Endpoint exceptions').click(); - cy.visit(APP_MANAGE_PATH); - cy.getByTestSubj('pageContainer').contains('Endpoint exceptions'); - }); + cy.getByTestSubj('endpointExceptionsListPage-container').should('exist'); + }); - it('should be able to navigate to Endpoint Exceptions from Administration page', () => { - loginWithReadAccess(); - cy.visit(APP_MANAGE_PATH); - cy.getByTestSubj('pageContainer').contains('Endpoint exceptions').click(); + it('should display Endpoint Exceptions in Manage side panel', () => { + loginWithReadAccess(); - cy.getByTestSubj('endpointExceptionsListPage-container').should('exist'); - }); + cy.visit(APP_PATH); + + essSecurityHeaders.openNavigationPanelFor(essSecurityHeaders.ENDPOINT_EXCEPTIONS); + cy.get(essSecurityHeaders.ENDPOINT_EXCEPTIONS).should('exist'); + }); - it('should display Endpoint Exceptions in Manage side panel', () => { - loginWithReadAccess(); + it('should be able to navigate to Endpoint Exceptions from Manage side panel', () => { + loginWithReadAccess(); + cy.visit(APP_PATH); - cy.visit(APP_PATH); + essSecurityHeaders.openNavigationPanelFor(essSecurityHeaders.ENDPOINT_EXCEPTIONS); + cy.get(essSecurityHeaders.ENDPOINT_EXCEPTIONS).click(); - essSecurityHeaders.openNavigationPanelFor(essSecurityHeaders.ENDPOINT_EXCEPTIONS); - cy.get(essSecurityHeaders.ENDPOINT_EXCEPTIONS).should('exist'); + cy.getByTestSubj('endpointExceptionsListPage-container').should('exist'); + }); + + // todo: add 'should NOT' test case when Endpoint Exceptions sub-feature privilege is separated from Security }); - it('should be able to navigate to Endpoint Exceptions from Manage side panel', () => { - loginWithReadAccess(); - cy.visit(APP_PATH); + describe('Serverless', { tags: ['@serverless', '@skipInServerlessMKI'] }, () => { + it('should display Endpoint Exceptions in Assets side panel ', () => { + // testing with t3_analyst with WRITE access, as we don't support custom roles on serverless yet + login(ROLE.t3_analyst); - essSecurityHeaders.openNavigationPanelFor(essSecurityHeaders.ENDPOINT_EXCEPTIONS); - cy.get(essSecurityHeaders.ENDPOINT_EXCEPTIONS).click(); + cy.visit(APP_PATH); - cy.getByTestSubj('endpointExceptionsListPage-container').should('exist'); - }); + serverlessSecurityHeaders.showMoreItems(); + serverlessSecurityHeaders.openNavigationPanelFor( + serverlessSecurityHeaders.ENDPOINT_EXCEPTIONS + ); + cy.get(serverlessSecurityHeaders.ENDPOINT_EXCEPTIONS).should('exist'); + }); - // todo: add 'should NOT' test case when Endpoint Exceptions sub-feature privilege is separated from Security + // todo: add 'should NOT' test case when custom roles are available on serverless + }); }); - describe('Serverless', { tags: ['@serverless', '@skipInServerlessMKI'] }, () => { - it('should display Endpoint Exceptions in Assets side panel ', () => { - // testing with t3_analyst with WRITE access, as we don't support custom roles on serverless yet - login(ROLE.t3_analyst); - - cy.visit(APP_PATH); + describe('Per-policy opt-in behaviour', { tags: ['@ess'] }, () => { + before(() => { + removeAllArtifacts(); - serverlessSecurityHeaders.showMoreItems(); - serverlessSecurityHeaders.openNavigationPanelFor( - serverlessSecurityHeaders.ENDPOINT_EXCEPTIONS + createArtifactList(ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id); + createPerPolicyArtifact( + 'an endpoint exception', + getArtifactsListTestDataForArtifact('endpointExceptions').createRequestBody ); - cy.get(serverlessSecurityHeaders.ENDPOINT_EXCEPTIONS).should('exist'); + resetEndpointExceptionPerPolicyOptInStatus(); }); - // todo: add 'should NOT' test case when custom roles are available on serverless + after(() => { + resetEndpointExceptionPerPolicyOptInStatus(); + removeAllArtifacts(); + }); + + it('should allow superuser to opt-in to per-policy Endpoint exceptions', () => { + cy.intercept('POST', ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE).as('sendOptInStatus'); + + fetchEndpointExceptionPerPolicyOptInStatus().then((status) => { + expect(status).to.equal(false); + }); + + login('elastic'); + cy.visit(APP_ENDPOINT_EXCEPTIONS_PATH); + + cy.getByTestSubj('updateDetailsEndpointExceptionsPerPolicyOptInButton').click(); + + cy.getByTestSubj('confirmEndpointExceptionsPerPolicyOptInButton').click(); + cy.wait('@sendOptInStatus'); + + cy.contains('Updated to policy-based exceptions'); + cy.contains('You can now apply your endpoint exceptions on a policy basis.'); + cy.getByTestSubj('updateDetailsEndpointExceptionsPerPolicyOptInButton').should('not.exist'); + + fetchEndpointExceptionPerPolicyOptInStatus().then((status) => { + expect(status).to.equal(true); + }); + }); }); } ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/support/artifacts_rbac_runner.ts b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/support/artifacts_rbac_runner.ts index 9b752538b27a1..d68eed69ad11f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/support/artifacts_rbac_runner.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/support/artifacts_rbac_runner.ts @@ -12,6 +12,7 @@ import { type ArtifactsFixtureType } from '../fixtures/artifacts_page'; import { createArtifactList, createPerPolicyArtifact, + enableEndpointExceptionPerPolicyOptIn, removeAllArtifacts, } from '../tasks/artifacts'; import { performUserActions } from '../tasks/perform_user_actions'; @@ -52,6 +53,8 @@ export const getArtifactMockedDataTests = (testData: ArtifactsFixtureType) => () indexEndpointHosts().then((indexEndpoints) => { endpointData = indexEndpoints; }); + + enableEndpointExceptionPerPolicyOptIn(); }); beforeEach(() => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/tasks/artifacts.ts b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/tasks/artifacts.ts index 733b93153e6c4..a5ff0d6055be1 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/tasks/artifacts.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/tasks/artifacts.ts @@ -18,12 +18,14 @@ import { EXCEPTION_LIST_ITEM_URL, EXCEPTION_LIST_URL, } from '@kbn/securitysolution-list-constants'; +import type { GetEndpointExceptionsPerPolicyOptInResponse } from '../../../../common/api/endpoint/endpoint_exceptions_per_policy_opt_in/endpoint_exceptions_per_policy_opt_in.gen'; import { APP_BLOCKLIST_PATH, APP_TRUSTED_APPS_PATH, APP_TRUSTED_DEVICES_PATH, } from '../../../../common/constants'; import { loadPage, request } from './common'; +import { ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE } from '../../../../common/endpoint/constants'; export const removeAllArtifacts = () => { for (const listId of ENDPOINT_ARTIFACT_LIST_IDS) { @@ -443,3 +445,33 @@ export const blocklistFormSelectors = { cy.getByTestSubj('blocklistPage-deleteModal-submitButton').click(); }, }; + +export const fetchEndpointExceptionPerPolicyOptInStatus = (): Cypress.Chainable => + request({ + method: 'GET', + url: ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE, + headers: { 'elastic-api-version': '1' }, + }).then((response) => response.body.status); + +export const enableEndpointExceptionPerPolicyOptIn = () => + request({ + method: 'POST', + url: ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE, + headers: { 'elastic-api-version': '1' }, + failOnStatusCode: false, + }); + +export const resetEndpointExceptionPerPolicyOptInStatus = () => { + const index = '.kibana_security_solution'; + const id = 'security:reference-data:ENDPOINT-EXCEPTIONS-PER-POLICY-OPT-IN-STATUS'; + + return cy.request({ + method: 'DELETE', + url: `${Cypress.env('ELASTICSEARCH_URL')}/${index}/_doc/${id}`, + auth: { + user: Cypress.env('ELASTICSEARCH_USERNAME'), + pass: Cypress.env('ELASTICSEARCH_PASSWORD'), + }, + failOnStatusCode: false, + }); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/hooks/artifacts/use_endpoint_per_policy_opt_in.test.ts b/x-pack/solutions/security/plugins/security_solution/public/management/hooks/artifacts/use_endpoint_per_policy_opt_in.test.ts new file mode 100644 index 0000000000000..42fc5ffe72f07 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/management/hooks/artifacts/use_endpoint_per_policy_opt_in.test.ts @@ -0,0 +1,51 @@ +/* + * 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 { + createAppRootMockRenderer, + type AppContextTestRender, +} from '../../../common/mock/endpoint'; +import { waitFor } from '@testing-library/dom'; +import { useGetEndpointExceptionsPerPolicyOptIn } from './use_endpoint_per_policy_opt_in'; +import { ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE } from '../../../../common/endpoint/constants'; + +describe('useGetEndpointExceptionsPerPolicyOptIn()', () => { + let testContext: AppContextTestRender; + + beforeEach(() => { + testContext = createAppRootMockRenderer(); + testContext.coreStart.http.get.mockResolvedValue({ isOptedIn: false }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should call the API when the experimental feature is enabled', async () => { + testContext.setExperimentalFlag({ endpointExceptionsMovedUnderManagement: true }); + + const { result } = testContext.renderHook(() => useGetEndpointExceptionsPerPolicyOptIn()); + + await waitFor(() => { + expect(result.current.data).toEqual({ isOptedIn: false }); + expect(testContext.coreStart.http.get).toHaveBeenCalledWith( + ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE, + { version: '1' } + ); + }); + }); + + it('should not call the API when the experimental feature is disabled', () => { + const { result } = testContext.renderHook(() => useGetEndpointExceptionsPerPolicyOptIn()); + + expect(result.current.data).toBeUndefined(); + expect(testContext.coreStart.http.get).not.toHaveBeenCalledWith( + ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE, + expect.anything() + ); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/hooks/artifacts/use_endpoint_per_policy_opt_in.ts b/x-pack/solutions/security/plugins/security_solution/public/management/hooks/artifacts/use_endpoint_per_policy_opt_in.ts new file mode 100644 index 0000000000000..87ea7bc6d684e --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/management/hooks/artifacts/use_endpoint_per_policy_opt_in.ts @@ -0,0 +1,42 @@ +/* + * 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 type { UseQueryResult } from '@kbn/react-query'; +import { useMutation, useQuery } from '@kbn/react-query'; +import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; +import type { GetEndpointExceptionsPerPolicyOptInResponse } from '../../../../common/api/endpoint/endpoint_exceptions_per_policy_opt_in/endpoint_exceptions_per_policy_opt_in.gen'; +import { ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE } from '../../../../common/endpoint/constants'; +import { useHttp } from '../../../common/lib/kibana'; + +export const useSendEndpointExceptionsPerPolicyOptIn = () => { + const http = useHttp(); + + return useMutation({ + mutationFn: () => { + return http.post(ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE, { version: '1' }); + }, + }); +}; + +export const useGetEndpointExceptionsPerPolicyOptIn = (): UseQueryResult< + GetEndpointExceptionsPerPolicyOptInResponse, + Error +> => { + const http = useHttp(); + const isEndpointExceptionsMovedUnderManagementFFEnabled = useIsExperimentalFeatureEnabled( + 'endpointExceptionsMovedUnderManagement' + ); + + return useQuery({ + queryKey: ['endpointExceptionsPerPolicyOptIn'], + enabled: isEndpointExceptionsMovedUnderManagementFFEnabled, + queryFn: async () => + http.get( + ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE, + { version: '1' } + ), + }); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/links.ts b/x-pack/solutions/security/plugins/security_solution/public/management/links.ts index 1613f550b0ac1..9a36cd3874f74 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/links.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/links.ts @@ -60,6 +60,7 @@ import { HostIsolationExceptionsApiClient } from './pages/host_isolation_excepti import { IconTrustedDevices } from '../common/icons/trusted_devices'; import { IconEndpointExceptions } from '../common/icons/endpoint_exceptions'; import { IconScriptLibrary } from '../common/icons/script_library'; +import { KibanaServices } from '../common/lib/kibana'; const categories = [ { @@ -270,6 +271,7 @@ export const getManagementFilteredLinks = async ( ): Promise => { const fleetAuthz = plugins.fleet?.authz; const currentUser = await plugins.security.authc.getCurrentUser(); + const isServerless = KibanaServices.getBuildFlavor() === 'serverless'; const { canReadActionsLogManagement, @@ -285,7 +287,7 @@ export const getManagementFilteredLinks = async ( canReadScriptsLibrary, } = fleetAuthz && currentUser - ? calculateEndpointAuthz(licenseService, fleetAuthz, currentUser.roles) + ? calculateEndpointAuthz(licenseService, fleetAuthz, currentUser.roles, isServerless) : getEndpointAuthzInitialState(); const showHostIsolationExceptions = diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/mocks/endpoint_per_policy_opt_in_http_mocks.ts b/x-pack/solutions/security/plugins/security_solution/public/management/mocks/endpoint_per_policy_opt_in_http_mocks.ts new file mode 100644 index 0000000000000..d4ca2db7b3dfa --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/management/mocks/endpoint_per_policy_opt_in_http_mocks.ts @@ -0,0 +1,54 @@ +/* + * 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 { ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE } from '../../../common/endpoint/constants'; +import type { GetEndpointExceptionsPerPolicyOptInResponse } from '../../../common/api/endpoint/endpoint_exceptions_per_policy_opt_in/endpoint_exceptions_per_policy_opt_in.gen'; +import { + composeHttpHandlerMocks, + httpHandlerMockFactory, + type ResponseProvidersInterface, +} from '../../common/mock/endpoint'; + +type SendEndpointExceptionsPerPolicyOptInMockInterface = ResponseProvidersInterface<{ + optInSend: () => void; +}>; + +const endpointExceptionsPerPolicyOptInSendHttpMocks = + httpHandlerMockFactory([ + { + id: 'optInSend', + path: ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE, + method: 'post', + handler: (): void => {}, + }, + ]); + +type GetEndpointExceptionsPerPolicyOptInMockInterface = ResponseProvidersInterface<{ + optInGet: () => GetEndpointExceptionsPerPolicyOptInResponse; +}>; + +const endpointExceptionsPerPolicyOptInGetHttpMocks = + httpHandlerMockFactory([ + { + id: 'optInGet', + path: ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE, + method: 'get', + handler: (): GetEndpointExceptionsPerPolicyOptInResponse => { + return { status: false }; + }, + }, + ]); + +type EndpointExceptionsPerPolicyOptInAllHttpMocksInterface = + GetEndpointExceptionsPerPolicyOptInMockInterface & + SendEndpointExceptionsPerPolicyOptInMockInterface; + +export const endpointExceptionsPerPolicyOptInAllHttpMocks = + composeHttpHandlerMocks([ + endpointExceptionsPerPolicyOptInGetHttpMocks, + endpointExceptionsPerPolicyOptInSendHttpMocks, + ]); diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/hooks/use_per_policy_opt_in.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/hooks/use_per_policy_opt_in.tsx new file mode 100644 index 0000000000000..dbd985e7af83d --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/hooks/use_per_policy_opt_in.tsx @@ -0,0 +1,115 @@ +/* + * 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 React, { useCallback, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import type { Action } from '@kbn/securitysolution-exception-list-components'; +import { useUserPrivileges } from '../../../../common/components/user_privileges'; +import { EndpointExceptionsPerPolicyOptInCallout } from '../view/components/per_policy_opt_in_callout'; +import { EndpointExceptionsPerPolicyOptInModal } from '../view/components/per_policy_opt_in_modal'; +import { useKibana, useToasts } from '../../../../common/lib/kibana'; +import { + useGetEndpointExceptionsPerPolicyOptIn, + useSendEndpointExceptionsPerPolicyOptIn, +} from '../../../hooks/artifacts/use_endpoint_per_policy_opt_in'; + +const STORAGE_KEY = 'endpointExceptionsPerPolicyOptInCalloutDismissed'; + +export const usePerPolicyOptIn = (): { + perPolicyOptInCallout: React.ReactNode | null; + perPolicyOptInModal: React.ReactNode | null; + perPolicyOptInActionMenuItem: Action | null; +} => { + const { sessionStorage } = useKibana().services; + const toasts = useToasts(); + const { canWriteAdminData, canCreateArtifactsByPolicy } = useUserPrivileges().endpointPrivileges; + + const { mutate, isLoading } = useSendEndpointExceptionsPerPolicyOptIn(); + const { data: isPerPolicyOptIn, refetch } = useGetEndpointExceptionsPerPolicyOptIn(); + + const [isCalloutDismissed, setIsCalloutDismissed] = useState( + sessionStorage.get(STORAGE_KEY) === true + ); + const shouldShowCallout = + canCreateArtifactsByPolicy && isPerPolicyOptIn?.status === false && !isCalloutDismissed; + const shouldShowAction = + canCreateArtifactsByPolicy && isPerPolicyOptIn?.status === false && canWriteAdminData; + + const [isModalVisible, setIsModalVisible] = useState(false); + + const handleOnDismissCallout = () => { + sessionStorage.set(STORAGE_KEY, true); + setIsCalloutDismissed(true); + }; + + const handleOnClickUpdateDetails = useCallback(() => { + setIsModalVisible(true); + }, []); + + const handleOnDismissModal = useCallback(() => { + setIsModalVisible(false); + }, []); + + const handleOnConfirmModal = useCallback(() => { + mutate(undefined, { + onSuccess: () => { + setIsModalVisible(false); + refetch(); + + toasts.addSuccess({ + title: i18n.translate( + 'xpack.securitySolution.endpointExceptions.perPolicyOptInModal.successTitle', + { defaultMessage: 'Updated to policy-based exceptions' } + ), + text: i18n.translate( + 'xpack.securitySolution.endpointExceptions.perPolicyOptInModal.successText', + { defaultMessage: 'You can now apply your endpoint exceptions on a policy basis.' } + ), + }); + }, + + onError: (error) => { + toasts.addError(error, { + title: i18n.translate( + 'xpack.securitySolution.endpointExceptions.perPolicyOptInModal.errorTitle', + { defaultMessage: 'Error updating to policy-based exceptions' } + ), + }); + }, + }); + }, [mutate, refetch, toasts]); + + return { + perPolicyOptInCallout: shouldShowCallout ? ( + + ) : null, + + perPolicyOptInModal: isModalVisible ? ( + + ) : null, + + perPolicyOptInActionMenuItem: shouldShowAction + ? { + key: 'perPolicyOptInActionMenuItem', + icon: 'check', + label: i18n.translate( + 'xpack.securitySolution.endpointExceptions.perPolicyOptInActionMenuItem.label', + { defaultMessage: 'Update to policy-based exceptions' } + ), + onClick: handleOnClickUpdateDetails, + } + : null, + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/components/endpoint_exceptions_form.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/components/endpoint_exceptions_form.test.tsx index d524f04c3d006..2915735e6ff09 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/components/endpoint_exceptions_form.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/components/endpoint_exceptions_form.test.tsx @@ -22,6 +22,9 @@ import { GLOBAL_ARTIFACT_TAG } from '../../../../../../common/endpoint/service/a import { useFetchPolicyData } from '../../../../components/policy_selector/hooks/use_fetch_policy_data'; import type { PackagePolicy } from '@kbn/fleet-plugin/common'; import { buildPerPolicyTag } from '../../../../../../common/endpoint/service/artifacts/utils'; +import { useGetEndpointExceptionsPerPolicyOptIn } from '../../../../hooks/artifacts/use_endpoint_per_policy_opt_in'; +import type { UseQueryResult } from '@kbn/react-query'; +import type { GetEndpointExceptionsPerPolicyOptInResponse } from '../../../../../../common/api/endpoint/endpoint_exceptions_per_policy_opt_in/endpoint_exceptions_per_policy_opt_in.gen'; jest.setTimeout(15_000); @@ -30,6 +33,12 @@ jest.mock('../../../../../common/lib/kibana'); jest.mock('../../../../../common/containers/source'); jest.mock('../../../../../common/hooks/use_license'); jest.mock('../../../../components/policy_selector/hooks/use_fetch_policy_data'); +jest.mock('../../../../hooks/artifacts/use_endpoint_per_policy_opt_in'); + +const mockedUseGetEndpointExceptionsPerPolicyOptIn = + useGetEndpointExceptionsPerPolicyOptIn as jest.MockedFunction< + typeof useGetEndpointExceptionsPerPolicyOptIn + >; /** When some props and states change, `EndpointExceptionsForm` will recreate its internal `processChanged` function, * and therefore will call it from a `useEffect` hook. @@ -151,6 +160,9 @@ describe('Endpoint exceptions form', () => { ] as PackagePolicy[], }, }); + mockedUseGetEndpointExceptionsPerPolicyOptIn.mockReturnValue({ + data: { status: true }, + } as UseQueryResult); formProps = { item: latestUpdatedItem, @@ -507,6 +519,15 @@ describe('Endpoint exceptions form', () => { mockLicenseService.isPlatinumPlus.mockReturnValue(true); }); + it('should not display policy assignment when user has not opted in', async () => { + mockedUseGetEndpointExceptionsPerPolicyOptIn.mockReturnValue({ + data: { status: false }, + } as UseQueryResult); + await act(() => render()); + + expect(renderResult.queryByTestId(`${formPrefix}-effectedPolicies`)).not.toBeInTheDocument(); + }); + it('should not display policy assignment when license is below platinum', async () => { mockLicenseService.isPlatinumPlus.mockReturnValue(false); await act(() => render()); @@ -514,7 +535,7 @@ describe('Endpoint exceptions form', () => { expect(renderResult.queryByTestId(`${formPrefix}-effectedPolicies`)).not.toBeInTheDocument(); }); - it('should display policy assignment when license is at least platinum', async () => { + it('should display policy assignment when license is at least platinum and opt-in is true', async () => { await act(() => render()); expect(renderResult.queryByTestId(`${formPrefix}-effectedPolicies`)).toBeInTheDocument(); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/components/endpoint_exceptions_form.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/components/endpoint_exceptions_form.tsx index 8084f0028031a..b11705bf82850 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/components/endpoint_exceptions_form.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/components/endpoint_exceptions_form.tsx @@ -36,6 +36,7 @@ import { OperatingSystem } from '@kbn/securitysolution-utils'; import { getExceptionBuilderComponentLazy } from '@kbn/lists-plugin/public'; import type { OnChangeProps } from '@kbn/lists-plugin/public'; import type { ValueSuggestionsGetFn } from '@kbn/kql/public/autocomplete/providers/value_suggestion_provider'; +import { useGetEndpointExceptionsPerPolicyOptIn } from '../../../../hooks/artifacts/use_endpoint_per_policy_opt_in'; import type { EffectedPolicySelectProps } from '../../../../components/effected_policy_select'; import { EffectedPolicySelect } from '../../../../components/effected_policy_select'; import { useCanAssignArtifactPerPolicy } from '../../../../hooks/artifacts'; @@ -140,7 +141,14 @@ export const EndpointExceptionsForm: React.FC = mem hasPartialCodeSignatureEntry([exception]) ); - const showAssignmentSection = useCanAssignArtifactPerPolicy(exception, mode, hasFormChanged); + const { data: isPerPolicyOptIn } = useGetEndpointExceptionsPerPolicyOptIn(); + + const canAssignArtifactPerPolicy = useCanAssignArtifactPerPolicy( + exception, + mode, + hasFormChanged + ); + const showAssignmentSection = isPerPolicyOptIn?.status === true && canAssignArtifactPerPolicy; const [isIndexPatternLoading, { indexPatterns }] = useFetchIndex( ENDPOINT_ALERTS_INDEX_NAMES, diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/components/per_policy_opt_in_callout.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/components/per_policy_opt_in_callout.test.tsx new file mode 100644 index 0000000000000..1966bf321be84 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/components/per_policy_opt_in_callout.test.tsx @@ -0,0 +1,90 @@ +/* + * 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 React from 'react'; +import type { EndpointExceptionsPerPolicyOptInCalloutProps } from './per_policy_opt_in_callout'; +import { EndpointExceptionsPerPolicyOptInCallout } from './per_policy_opt_in_callout'; +import { + createAppRootMockRenderer, + type AppContextTestRender, +} from '../../../../../common/mock/endpoint'; + +describe('EndpointExceptionsPerPolicyOptInCallout', () => { + let props: EndpointExceptionsPerPolicyOptInCalloutProps; + let render: () => ReturnType; + let renderResult: ReturnType; + + beforeEach(() => { + props = { + onDismiss: jest.fn(), + onClickUpdateDetails: jest.fn(), + canOptIn: true, + }; + + const mockedContext = createAppRootMockRenderer(); + render = () => + (renderResult = mockedContext.render()); + }); + + it('renders', () => { + render(); + + expect(renderResult.getByText('Endpoint Exceptions are now managed here')).toBeInTheDocument(); + + expect(props.onDismiss).not.toHaveBeenCalled(); + expect(props.onClickUpdateDetails).not.toHaveBeenCalled(); + }); + + it('displays the update details button when user has permissions', () => { + props.canOptIn = true; + + render(); + + const updateDetailsButton = renderResult.getByTestId( + 'updateDetailsEndpointExceptionsPerPolicyOptInButton' + ); + expect(updateDetailsButton).toBeInTheDocument(); + }); + + it('displays the contact admin message when user does not have permissions', () => { + props.canOptIn = false; + + render(); + + const noPermissionMessage = renderResult.getByText( + /Contact your administrator to update details./ + ); + expect(noPermissionMessage).toBeInTheDocument(); + + const updateDetailsButton = renderResult.queryByTestId( + 'updateDetailsEndpointExceptionsPerPolicyOptInButton' + ); + expect(updateDetailsButton).not.toBeInTheDocument(); + }); + + it('calls onClickUpdateDetails when update details button is clicked', () => { + render(); + + const updateDetailsButton = renderResult.getByTestId( + 'updateDetailsEndpointExceptionsPerPolicyOptInButton' + ); + updateDetailsButton.click(); + + expect(props.onClickUpdateDetails).toHaveBeenCalled(); + expect(props.onDismiss).not.toHaveBeenCalled(); + }); + + it('calls onDismiss when cancel button is clicked', () => { + render(); + + const cancelButton = renderResult.getByTestId('euiDismissCalloutButton'); + cancelButton.click(); + + expect(props.onDismiss).toHaveBeenCalled(); + expect(props.onClickUpdateDetails).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/components/per_policy_opt_in_callout.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/components/per_policy_opt_in_callout.tsx new file mode 100644 index 0000000000000..8d336927ffc4e --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/components/per_policy_opt_in_callout.tsx @@ -0,0 +1,86 @@ +/* + * 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 { EuiButton, EuiCallOut, EuiFlexGroup, EuiLink, EuiSpacer } from '@elastic/eui'; +import React, { memo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useKibana } from '../../../../../common/lib/kibana'; + +export interface EndpointExceptionsPerPolicyOptInCalloutProps { + onDismiss: () => void; + onClickUpdateDetails: () => void; + canOptIn: boolean; +} + +export const EndpointExceptionsPerPolicyOptInCallout: React.FC = + memo(({ onDismiss, onClickUpdateDetails, canOptIn }) => { + const { docLinks } = useKibana().services; + + return ( + + + + + + {canOptIn ? ( + + + {i18n.translate( + 'xpack.securitySolution.endpointExceptions.perPolicyOptInCalloutCta', + { + defaultMessage: 'Update details', + } + )} + + + + + {i18n.translate( + 'xpack.securitySolution.endpointExceptions.perPolicyOptInCalloutLearnMore', + { + defaultMessage: 'Learn more', + } + )} + + + + ) : ( + + )} + + ); + }); + +EndpointExceptionsPerPolicyOptInCallout.displayName = 'EndpointExceptionsPerPolicyOptInCallout'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/components/per_policy_opt_in_modal.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/components/per_policy_opt_in_modal.test.tsx new file mode 100644 index 0000000000000..56770361cbc79 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/components/per_policy_opt_in_modal.test.tsx @@ -0,0 +1,74 @@ +/* + * 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 React from 'react'; +import type { EndpointExceptionsPerPolicyOptInModalProps } from './per_policy_opt_in_modal'; +import { EndpointExceptionsPerPolicyOptInModal } from './per_policy_opt_in_modal'; +import { + createAppRootMockRenderer, + type AppContextTestRender, +} from '../../../../../common/mock/endpoint'; + +describe('EndpointExceptionsPerPolicyOptInModal', () => { + let props: EndpointExceptionsPerPolicyOptInModalProps; + let render: () => ReturnType; + let renderResult: ReturnType; + + beforeEach(() => { + props = { + onDismiss: jest.fn(), + onConfirm: jest.fn(), + isLoading: false, + }; + + const mockedContext = createAppRootMockRenderer(); + render = () => + (renderResult = mockedContext.render()); + }); + + it('renders', () => { + render(); + + expect( + renderResult.getByText('Update to policy-based endpoint exceptions') + ).toBeInTheDocument(); + + expect(props.onDismiss).not.toHaveBeenCalled(); + expect(props.onConfirm).not.toHaveBeenCalled(); + }); + + it('calls onConfirm when confirm button is clicked', () => { + render(); + + const confirmButton = renderResult.getByTestId('confirmEndpointExceptionsPerPolicyOptInButton'); + confirmButton.click(); + + expect(props.onConfirm).toHaveBeenCalled(); + expect(props.onDismiss).not.toHaveBeenCalled(); + }); + + it('calls onDismiss when cancel button is clicked', () => { + render(); + + const cancelButton = renderResult.getByTestId('cancelEndpointExceptionsPerPolicyOptInButton'); + cancelButton.click(); + + expect(props.onDismiss).toHaveBeenCalled(); + expect(props.onConfirm).not.toHaveBeenCalled(); + }); + + it('disables buttons when isLoading is true', () => { + props.isLoading = true; + render(); + + const confirmButton = renderResult.getByTestId('confirmEndpointExceptionsPerPolicyOptInButton'); + expect(confirmButton).toBeDisabled(); + + const cancelButton = renderResult.getByTestId('cancelEndpointExceptionsPerPolicyOptInButton'); + expect(cancelButton).toBeDisabled(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/components/per_policy_opt_in_modal.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/components/per_policy_opt_in_modal.tsx new file mode 100644 index 0000000000000..5ed1a1d53be1b --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/components/per_policy_opt_in_modal.tsx @@ -0,0 +1,137 @@ +/* + * 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 { + EuiButton, + EuiButtonEmpty, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import React, { memo } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; + +export interface EndpointExceptionsPerPolicyOptInModalProps { + onDismiss: () => void; + onConfirm: () => void; + isLoading: boolean; +} + +export const EndpointExceptionsPerPolicyOptInModal: React.FC = + memo(({ onDismiss, onConfirm, isLoading }) => { + return ( + + + + + + + + + +

+ +

+ +
+ + +
    +
  • + +
  • +
  • + +
  • +
+
+ + + +
+ + +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+ + + + + + + + + + +
+ ); + }); + +EndpointExceptionsPerPolicyOptInModal.displayName = 'EndpointExceptionsPerPolicyOptInModal'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/endpoint_exceptions.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/endpoint_exceptions.test.tsx index 02820d371d953..69e3f9e58699f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/endpoint_exceptions.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/endpoint_exceptions.test.tsx @@ -13,6 +13,9 @@ import { createAppRootMockRenderer } from '../../../../common/mock/endpoint'; import { EndpointExceptions } from './endpoint_exceptions'; import { useUserPrivileges } from '../../../../common/components/user_privileges'; import { getEndpointAuthzInitialStateMock } from '../../../../../common/endpoint/service/authz/mocks'; +import { exceptionsListAllHttpMocks } from '../../../mocks'; +import { endpointExceptionsPerPolicyOptInAllHttpMocks } from '../../../mocks/endpoint_per_policy_opt_in_http_mocks'; +import userEvent from '@testing-library/user-event'; jest.mock('../../../../common/components/user_privileges'); const mockUserPrivileges = useUserPrivileges as jest.Mock; @@ -26,6 +29,8 @@ describe('When on the endpoint exceptions page', () => { beforeEach(() => { mockedContext = createAppRootMockRenderer(); ({ history } = mockedContext); + mockedContext.setExperimentalFlag({ endpointExceptionsMovedUnderManagement: true }); + render = () => (renderResult = mockedContext.render()); act(() => { @@ -41,7 +46,9 @@ describe('When on the endpoint exceptions page', () => { it('should show the Empty message', async () => { render(); await waitFor(() => - expect(renderResult.getByTestId('endpointExceptionsListPage-emptyState')).toBeTruthy() + expect( + renderResult.getByTestId('endpointExceptionsListPage-emptyState') + ).toBeInTheDocument() ); }); }); @@ -52,6 +59,7 @@ describe('When on the endpoint exceptions page', () => { mockUserPrivileges.mockReturnValue({ endpointPrivileges: getEndpointAuthzInitialStateMock({ canWriteEndpointExceptions: true, + canWriteAdminData: false, }), }); }); @@ -62,7 +70,7 @@ describe('When on the endpoint exceptions page', () => { await waitFor(() => expect( renderResult.queryByTestId('endpointExceptionsListPage-emptyState-addButton') - ).toBeTruthy() + ).toBeInTheDocument() ); }); }); @@ -72,6 +80,7 @@ describe('When on the endpoint exceptions page', () => { mockUserPrivileges.mockReturnValue({ endpointPrivileges: getEndpointAuthzInitialStateMock({ canWriteEndpointExceptions: false, + canWriteAdminData: false, }), }); }); @@ -80,7 +89,9 @@ describe('When on the endpoint exceptions page', () => { render(); await waitFor(() => - expect(renderResult.queryByTestId('endpointExceptionsListPage-container')).toBeTruthy() + expect( + renderResult.queryByTestId('endpointExceptionsListPage-container') + ).toBeInTheDocument() ); expect( @@ -89,4 +100,296 @@ describe('When on the endpoint exceptions page', () => { }); }); }); + + describe('When opting in to per-policy Endpoint exceptions', () => { + const CALLOUT = 'endpointExceptionsPerPolicyOptInCallout'; + const MODAL = 'endpointExceptionsPerPolicyOptInModal'; + const STORAGE_KEY = 'endpointExceptionsPerPolicyOptInCalloutDismissed'; + const UPDATE_DETAILS_BTN = 'updateDetailsEndpointExceptionsPerPolicyOptInButton'; + const CANCEL_BTN = 'cancelEndpointExceptionsPerPolicyOptInButton'; + const CONFIRM_BTN = 'confirmEndpointExceptionsPerPolicyOptInButton'; + const MENU_BTN = 'endpointExceptionsListPage-overflowMenuButtonIcon'; + const UPDATE_TO_PER_POLICY_ACTION_BTN = + 'endpointExceptionsListPage-overflowMenuActionItemperPolicyOptInActionMenuItem'; + + let optInGetMock: ReturnType< + typeof endpointExceptionsPerPolicyOptInAllHttpMocks + >['responseProvider']['optInGet']; + + let optInSendMock: ReturnType< + typeof endpointExceptionsPerPolicyOptInAllHttpMocks + >['responseProvider']['optInSend']; + + beforeEach(() => { + const perPolicyOptInMocks = endpointExceptionsPerPolicyOptInAllHttpMocks( + mockedContext.coreStart.http + ); + optInGetMock = perPolicyOptInMocks.responseProvider.optInGet; + optInSendMock = perPolicyOptInMocks.responseProvider.optInSend; + + mockUserPrivileges.mockReturnValue({ + endpointPrivileges: getEndpointAuthzInitialStateMock({ + canWriteEndpointExceptions: true, + canWriteAdminData: true, + }), + }); + }); + + describe('when there are no exceptions', () => { + it('should not show the per-policy opt-in callout', async () => { + render(); + + expect(renderResult.queryByTestId(CALLOUT)).toBeNull(); + }); + }); + + describe('when there are exceptions', () => { + beforeEach(() => { + exceptionsListAllHttpMocks(mockedContext.coreStart.http); + }); + + describe('when showing the callout or not', () => { + it('should show the per-policy opt-in callout by default', async () => { + render(); + + await waitFor(() => expect(optInGetMock).toHaveBeenCalled()); + expect(renderResult.queryByTestId(CALLOUT)).toBeTruthy(); + }); + + it('should not show the per-policy opt-in below Platinum license', async () => { + mockUserPrivileges.mockReturnValue({ + endpointPrivileges: getEndpointAuthzInitialStateMock({ + canCreateArtifactsByPolicy: false, + }), + }); + + render(); + + await waitFor(() => expect(optInGetMock).toHaveBeenCalled()); + expect(renderResult.queryByTestId(CALLOUT)).not.toBeInTheDocument(); + }); + + it('should hide the per-policy opt-in callout after dismissing it and store the dismissal in session storage', async () => { + render(); + + expect(mockedContext.startServices.sessionStorage.get(STORAGE_KEY)).toEqual(null); + + await waitFor(() => expect(optInGetMock).toHaveBeenCalled()); + + await userEvent.click( + renderResult + .getByTestId(CALLOUT) + .querySelector('[data-test-subj="euiDismissCalloutButton"]')! + ); + + expect(renderResult.queryByTestId(CALLOUT)).not.toBeInTheDocument(); + expect(mockedContext.startServices.sessionStorage.get(STORAGE_KEY)).toEqual(true); + }); + + it('should not show the per-policy callout if the dismissal is stored in session storage', async () => { + mockedContext.startServices.sessionStorage.set(STORAGE_KEY, true); + + render(); + + await waitFor(() => expect(optInGetMock).toHaveBeenCalled()); + expect(renderResult.queryByTestId(CALLOUT)).not.toBeInTheDocument(); + }); + + it('should not show the callout after user opted in', async () => { + optInGetMock.mockReturnValue({ status: true }); + + render(); + + await waitFor(() => expect(optInGetMock).toHaveBeenCalled()); + expect(renderResult.queryByTestId(CALLOUT)).not.toBeInTheDocument(); + }); + }); + + describe('when using the opt-in action menu item', () => { + it('should show the opt-in menu action even when the callout is dismissed', async () => { + mockedContext.startServices.sessionStorage.set(STORAGE_KEY, true); + + render(); + + await waitFor(() => expect(optInGetMock).toHaveBeenCalled()); + await waitFor(() => userEvent.click(renderResult.getByTestId(MENU_BTN))); + + expect(renderResult.queryByTestId(UPDATE_TO_PER_POLICY_ACTION_BTN)).toBeInTheDocument(); + expect(renderResult.queryByTestId(CALLOUT)).not.toBeInTheDocument(); + }); + + it('should not show the opt-in menu action below Platinum license', async () => { + mockUserPrivileges.mockReturnValue({ + endpointPrivileges: getEndpointAuthzInitialStateMock({ + canCreateArtifactsByPolicy: false, + }), + }); + + render(); + + await waitFor(() => expect(optInGetMock).toHaveBeenCalled()); + expect( + renderResult.queryByTestId(UPDATE_TO_PER_POLICY_ACTION_BTN) + ).not.toBeInTheDocument(); + }); + + it('should show the per-policy opt-in modal when clicking on the action menu item', async () => { + render(); + + await waitFor(() => userEvent.click(renderResult.getByTestId(MENU_BTN))); + await waitFor(() => + userEvent.click(renderResult.getByTestId(UPDATE_TO_PER_POLICY_ACTION_BTN)) + ); + + expect(renderResult.queryByTestId(MODAL)).toBeInTheDocument(); + }); + + it('should hide the per-policy opt-in modal when already opted in', async () => { + optInGetMock.mockReturnValue({ status: true }); + + render(); + + await waitFor(() => userEvent.click(renderResult.getByTestId(MENU_BTN))); + + expect( + renderResult.queryByTestId(UPDATE_TO_PER_POLICY_ACTION_BTN) + ).not.toBeInTheDocument(); + }); + }); + + describe('when showing the opt-in modal', () => { + it('should show the modal when clicking on the update details button', async () => { + render(); + await waitFor(() => userEvent.click(renderResult.getByTestId(UPDATE_DETAILS_BTN))); + + expect(renderResult.queryByTestId(MODAL)).toBeInTheDocument(); + }); + + it('should hide the modal when clicking on the cancel button', async () => { + render(); + await waitFor(() => userEvent.click(renderResult.getByTestId(UPDATE_DETAILS_BTN))); + await waitFor(() => userEvent.click(renderResult.getByTestId(CANCEL_BTN))); + + expect(renderResult.queryByTestId(MODAL)).not.toBeInTheDocument(); + }); + + it('should call the opt-in API and show a success toast when clicking on the confirm button', async () => { + render(); + await waitFor(() => userEvent.click(renderResult.getByTestId(UPDATE_DETAILS_BTN))); + await waitFor(() => userEvent.click(renderResult.getByTestId(CONFIRM_BTN))); + + expect(optInSendMock).toHaveBeenCalled(); + expect(mockedContext.coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith({ + title: 'Updated to policy-based exceptions', + text: 'You can now apply your endpoint exceptions on a policy basis.', + }); + expect(renderResult.queryByTestId(MODAL)).not.toBeInTheDocument(); + }); + + it('should disable the buttons while the opt-in API call is in-flight', async () => { + let finishRequest: (_?: unknown) => void = () => {}; + optInSendMock.mockImplementation( + () => new Promise((resolve) => (finishRequest = resolve)) + ); + + render(); + await waitFor(() => userEvent.click(renderResult.getByTestId(UPDATE_DETAILS_BTN))); + await waitFor(() => userEvent.click(renderResult.getByTestId(CONFIRM_BTN))); + + expect(renderResult.getByTestId(CONFIRM_BTN)).toBeDisabled(); + expect(renderResult.getByTestId(CANCEL_BTN)).toBeDisabled(); + + act(() => { + finishRequest(); + }); + + await waitFor(() => expect(renderResult.queryByTestId(MODAL)).not.toBeInTheDocument()); + }); + + it('should show an error toast when the opt-in API call fails', async () => { + optInSendMock.mockImplementation(() => Promise.reject(new Error('Error message'))); + + render(); + await waitFor(() => userEvent.click(renderResult.getByTestId(UPDATE_DETAILS_BTN))); + await waitFor(() => userEvent.click(renderResult.getByTestId(CONFIRM_BTN))); + + expect(optInSendMock).toHaveBeenCalled(); + expect(mockedContext.coreStart.notifications.toasts.addError).toHaveBeenCalledWith( + new Error('Error message'), + { title: 'Error updating to policy-based exceptions' } + ); + expect(renderResult.queryByTestId(MODAL)).toBeInTheDocument(); + }); + }); + + describe('RBAC', () => { + describe('when user has the `canWriteAdminData` privilege', () => { + beforeEach(() => { + mockUserPrivileges.mockReturnValue({ + endpointPrivileges: getEndpointAuthzInitialStateMock({ + canWriteEndpointExceptions: true, + canWriteAdminData: true, + }), + }); + }); + + it('should show the update details button', async () => { + render(); + + await waitFor(() => { + expect(renderResult.queryByTestId(CALLOUT)).toBeInTheDocument(); + expect(renderResult.queryByTestId(UPDATE_DETAILS_BTN)).toBeInTheDocument(); + }); + }); + + it('should show the opt in menu action', async () => { + render(); + + await waitFor(() => userEvent.click(renderResult.getByTestId(MENU_BTN))); + + await waitFor(() => { + expect( + renderResult.queryByTestId(UPDATE_TO_PER_POLICY_ACTION_BTN) + ).toBeInTheDocument(); + }); + }); + }); + + describe('when user does not have the `canWriteAdminData` privilege', () => { + beforeEach(() => { + mockUserPrivileges.mockReturnValue({ + endpointPrivileges: getEndpointAuthzInitialStateMock({ + canWriteEndpointExceptions: true, + canWriteAdminData: false, + }), + }); + }); + + it('should show "Contact your admin" instead of the update details button', async () => { + render(); + + await waitFor(() => { + expect(renderResult.queryByTestId(CALLOUT)).toBeInTheDocument(); + expect( + renderResult.queryByText(/Contact your administrator to update details/) + ).toBeInTheDocument(); + expect(renderResult.queryByTestId(UPDATE_DETAILS_BTN)).not.toBeInTheDocument(); + }); + }); + + it('should not show the opt in menu action', async () => { + render(); + + await waitFor(() => userEvent.click(renderResult.getByTestId(MENU_BTN))); + + await waitFor(() => { + expect( + renderResult.queryByTestId(UPDATE_TO_PER_POLICY_ACTION_BTN) + ).not.toBeInTheDocument(); + }); + }); + }); + }); + }); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/endpoint_exceptions.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/endpoint_exceptions.tsx index 00587020fd505..f299c33245381 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/endpoint_exceptions.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/endpoint_exceptions.tsx @@ -14,24 +14,35 @@ import { EndpointExceptionsForm } from './components/endpoint_exceptions_form'; import { EndpointExceptionsApiClient } from '../service/api_client'; import { ENDPOINT_EXCEPTIONS_SEARCHABLE_FIELDS } from '../constants'; import { ENDPOINT_EXCEPTIONS_PAGE_LABELS } from '../translations'; +import { usePerPolicyOptIn } from '../hooks/use_per_policy_opt_in'; export const EndpointExceptions = memo(() => { const { canWriteEndpointExceptions } = useUserPrivileges().endpointPrivileges; const http = useHttp(); const endpointExceptionsApiClient = EndpointExceptionsApiClient.getInstance(http); + const { perPolicyOptInCallout, perPolicyOptInModal, perPolicyOptInActionMenuItem } = + usePerPolicyOptIn(); + return ( - + <> + {perPolicyOptInModal} + + ); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.tsx index 88423c779fa2d..41d60dc2c0707 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.tsx @@ -46,6 +46,7 @@ export interface PolicyArtifactsLayoutProps { getPolicyArtifactsPath: (policyId: string) => string; /** A boolean to check if has write artifact privilege or not */ canWriteArtifact?: boolean; + disableArtifactsByPolicy?: boolean; // Artifact specific decorations to display in the cards CardDecorator?: React.ComponentType; } @@ -59,6 +60,7 @@ export const PolicyArtifactsLayout = React.memo( getPolicyArtifactsPath, canWriteArtifact = false, CardDecorator, + disableArtifactsByPolicy, }) => { const exceptionsListApiClient = useMemo( () => getExceptionsListApiClient(), @@ -75,6 +77,9 @@ export const PolicyArtifactsLayout = React.memo( ExceptionListItemSchema | undefined >(); + const showPolicyAssignment = + canCreateArtifactsByPolicy && !disableArtifactsByPolicy && canWriteArtifact; + const labels = useMemo(() => { return { ...policyArtifactsPageLabels, @@ -154,7 +159,7 @@ export const PolicyArtifactsLayout = React.memo( if (isEmptyState) { return ( <> - {canCreateArtifactsByPolicy && urlParams.show === 'list' && ( + {showPolicyAssignment && urlParams.show === 'list' && ( ( policyName={policyItem.name} listId={exceptionsListApiClient.listId} labels={labels} - canWriteArtifact={canWriteArtifact} + canWriteArtifact={showPolicyAssignment} getPolicyArtifactsPath={getPolicyArtifactsPath} getArtifactPath={getArtifactPath} /> @@ -203,10 +208,10 @@ export const PolicyArtifactsLayout = React.memo( - {canCreateArtifactsByPolicy && canWriteArtifact && assignToPolicyButton} + {showPolicyAssignment && assignToPolicyButton} - {canCreateArtifactsByPolicy && canWriteArtifact && urlParams.show === 'list' && ( + {showPolicyAssignment && urlParams.show === 'list' && ( { [http] ); + const { data: isPerPolicyOptIn } = useGetEndpointExceptionsPerPolicyOptIn(); + const tabs: Record = useMemo(() => { const trustedAppsLabels = { ...POLICY_ARTIFACT_TRUSTED_APPS_LABELS, @@ -481,6 +484,7 @@ export const PolicyTabs = React.memo(() => { getArtifactPath={getEndpointExceptionsListPath} getPolicyArtifactsPath={getPolicyEndpointExceptionsPath} canWriteArtifact={canWriteEndpointExceptions} + disableArtifactsByPolicy={!isPerPolicyOptIn?.status} /> ), @@ -532,6 +536,7 @@ export const PolicyTabs = React.memo(() => { canReadEndpointExceptions, getEndpointExceptionsApiClientInstance, canWriteEndpointExceptions, + isPerPolicyOptIn?.status, isEnterprise, ]); diff --git a/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/endpoint_artifact_services.ts b/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/endpoint_artifact_services.ts index 753377d9fd34c..43c3bbca65259 100644 --- a/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/endpoint_artifact_services.ts +++ b/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/endpoint_artifact_services.ts @@ -17,6 +17,7 @@ import type { CreateExceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; import { memoize } from 'lodash'; +import type { SavedObjectsFindResult } from '@kbn/core/server'; import { ENDPOINT_EXCEPTIONS_LIST_DEFINITION } from '../../../public/management/pages/endpoint_exceptions/constants'; import { catchAxiosErrorFormatAndThrow } from '../../../common/endpoint/format_axios_error'; import { TRUSTED_APPS_EXCEPTION_LIST_DEFINITION } from '../../../public/management/pages/trusted_apps/constants'; @@ -25,6 +26,15 @@ import { BLOCKLISTS_LIST_DEFINITION } from '../../../public/management/pages/blo import { HOST_ISOLATION_EXCEPTIONS_LIST_DEFINITION } from '../../../public/management/pages/host_isolation_exceptions/constants'; import type { NewTrustedApp } from '../../../common/endpoint/types'; import { newTrustedAppToCreateExceptionListItem } from '../../../public/management/pages/trusted_apps/service/mappers'; +import { ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE } from '../../../common/endpoint/constants'; +import type { + OptInStatusMetadata, + ReferenceDataSavedObject, +} from '../../../server/endpoint/lib/reference_data'; +import { + REF_DATA_KEYS, + REFERENCE_DATA_SAVED_OBJECT_TYPE, +} from '../../../server/endpoint/lib/reference_data'; export const ensureArtifactListExists = memoize( async ( @@ -139,3 +149,56 @@ export const createEndpointException = async ( await ensureArtifactListExists(kbnClient, 'hostIsolationExceptions'); return createExceptionListItem(kbnClient, data); }; + +export const findEndpointExceptionsPerPolicyOptInSO = async ( + kbnClient: KbnClient +): Promise> | undefined> => { + const foundReferenceDataSavedObjects = await kbnClient.savedObjects.find< + ReferenceDataSavedObject + >({ + type: REFERENCE_DATA_SAVED_OBJECT_TYPE, + }); + + return foundReferenceDataSavedObjects.saved_objects.find( + (obj) => obj.id === REF_DATA_KEYS.endpointExceptionsPerPolicyOptInStatus + ); +}; + +export const deleteEndpointExceptionsPerPolicyOptInSO = async ( + kbnClient: KbnClient +): Promise => { + const foundSO = await findEndpointExceptionsPerPolicyOptInSO(kbnClient); + + if (foundSO) { + await kbnClient.savedObjects.delete({ + type: REFERENCE_DATA_SAVED_OBJECT_TYPE, + id: foundSO.id, + }); + } +}; + +export const optInForPerPolicyEndpointExceptions = async (kbnClient: KbnClient): Promise => { + await kbnClient.request({ + method: 'POST', + path: ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE, + headers: { + 'x-elastic-internal-origin': 'kibana', + 'Elastic-Api-Version': '1', + 'kbn-xsrf': 'true', + }, + }); +}; + +export const disablePerPolicyEndpointExceptions = async (kbnClient: KbnClient): Promise => { + await kbnClient.savedObjects.create({ + type: REFERENCE_DATA_SAVED_OBJECT_TYPE, + id: REF_DATA_KEYS.endpointExceptionsPerPolicyOptInStatus, + attributes: { + id: REF_DATA_KEYS.endpointExceptionsPerPolicyOptInStatus, + owner: 'EDR', + type: 'OPT_IN_STATUS', + metadata: { status: false, reason: undefined }, + } as ReferenceDataSavedObject, + overwrite: true, + }); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts index e6f4f8d3eda5e..f54972a138b16 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts @@ -39,8 +39,8 @@ import { installScriptsLibraryIndexTemplates, SCRIPTS_LIBRARY_SAVED_OBJECT_TYPE, } from './lib/scripts_library'; -import type { ReferenceDataClientInterface } from './lib/reference_data'; -import { ReferenceDataClient } from './lib/reference_data'; +import type { OptInStatusMetadata, ReferenceDataClientInterface } from './lib/reference_data'; +import { REF_DATA_KEYS, ReferenceDataClient } from './lib/reference_data'; import type { TelemetryConfigProvider } from '../../common/telemetry_config/telemetry_config_provider'; import { SavedObjectsClientFactory } from './services/saved_objects'; import type { ResponseActionsClient } from './services'; @@ -260,8 +260,6 @@ export class EndpointAppContextService { throw new EndpointAppContentServicesNotSetUpError(); } - // TODO:PT check what this returns when running locally with kibana in serverless emulation - return Boolean(this.setupDependencies.cloud.isServerlessEnabled); } @@ -306,6 +304,7 @@ export class EndpointAppContextService { this.getLicenseService(), fleetAuthz, userRoles, + this.isServerless(), this.startDependencies.productFeaturesService ); } @@ -498,10 +497,33 @@ export class EndpointAppContextService { return new ReferenceDataClient( this.savedObjects.createInternalScopedSoClient({ readonly: false }), + this.experimentalFeatures, this.createLogger('ReferenceDataClient') ); } + /** + * Returns true if Endpoint Exceptions move FF is enabled AND the user has opted in + * to per-policy Endpoint Exceptions. + */ + public async isEndpointExceptionsPerPolicyEnabled(): Promise { + if (!this.startDependencies) { + throw new EndpointAppContentServicesNotStartedError(); + } + + if (!this.startDependencies.experimentalFeatures.endpointExceptionsMovedUnderManagement) { + return false; + } + + const referenceDataClient = this.getReferenceDataClient(); + + const optInStatusMetadata = await referenceDataClient.get( + REF_DATA_KEYS.endpointExceptionsPerPolicyOptInStatus + ); + + return optInStatusMetadata.metadata.status; + } + public getServerConfigValue( key: TKey ): ConfigType[TKey] { diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/lib/reference_data/constants.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/lib/reference_data/constants.ts index 2997924336b6d..a5b6e4319d067 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/lib/reference_data/constants.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/lib/reference_data/constants.ts @@ -5,12 +5,20 @@ * 2.0. */ +import type { SavedObjectsClientContract } from '@kbn/core/server'; +import { + ENDPOINT_ARTIFACT_LISTS, + EXCEPTION_LIST_NAMESPACE_AGNOSTIC, +} from '@kbn/securitysolution-list-constants'; +import type { ExperimentalFeatures } from '../../../../common'; import type { MigrationMetadata, + OptInStatusMetadata, OrphanResponseActionsMetadata, ReferenceDataItemKey, ReferenceDataSavedObject, } from './types'; +import { wrapErrorIfNeeded } from '../../utils'; export const REFERENCE_DATA_SAVED_OBJECT_TYPE = 'security:reference-data'; @@ -24,6 +32,10 @@ export const REF_DATA_KEYS = { * where orphan response actions (those whose associated with a policy that has been deleted). */ orphanResponseActionsSpace: 'ORPHAN-RESPONSE-ACTIONS-SPACE', + /** + * v9.4.0: Per-policy opt-in status for Endpoint exceptions + */ + endpointExceptionsPerPolicyOptInStatus: 'ENDPOINT-EXCEPTIONS-PER-POLICY-OPT-IN-STATUS', } as const; /** @@ -31,39 +43,95 @@ export const REF_DATA_KEYS = { * reference data key is fetch for the first time. */ export const REF_DATA_KEY_INITIAL_VALUE: Readonly< - Record ReferenceDataSavedObject> + Record< + ReferenceDataItemKey, + ( + soClient: SavedObjectsClientContract, + experimentalFeatures: ExperimentalFeatures + ) => Promise + > > = { - [REF_DATA_KEYS.spaceAwarenessArtifactMigration]: - (): ReferenceDataSavedObject => ({ - id: REF_DATA_KEYS.spaceAwarenessArtifactMigration, - owner: 'EDR', - type: 'MIGRATION', - metadata: { - started: new Date().toISOString(), - finished: '', - status: 'not-started', - data: {}, - }, - }), + [REF_DATA_KEYS.spaceAwarenessArtifactMigration]: async (): Promise< + ReferenceDataSavedObject + > => ({ + id: REF_DATA_KEYS.spaceAwarenessArtifactMigration, + owner: 'EDR', + type: 'MIGRATION', + metadata: { + started: new Date().toISOString(), + finished: '', + status: 'not-started', + data: {}, + }, + }), + + [REF_DATA_KEYS.spaceAwarenessResponseActionsMigration]: async (): Promise< + ReferenceDataSavedObject + > => ({ + id: REF_DATA_KEYS.spaceAwarenessResponseActionsMigration, + owner: 'EDR', + type: 'MIGRATION', + metadata: { + started: new Date().toISOString(), + finished: '', + status: 'not-started', + data: {}, + }, + }), + + [REF_DATA_KEYS.orphanResponseActionsSpace]: async (): Promise< + ReferenceDataSavedObject + > => ({ + id: REF_DATA_KEYS.orphanResponseActionsSpace, + owner: 'EDR', + type: 'RESPONSE-ACTIONS', + metadata: { spaceId: '' }, + }), + + [REF_DATA_KEYS.endpointExceptionsPerPolicyOptInStatus]: async ( + soClient: SavedObjectsClientContract, + experimentalFeatures: ExperimentalFeatures + ): Promise> => { + let shouldAutomaticallyOptIn = false; + + if (experimentalFeatures.endpointExceptionsMovedUnderManagement) { + try { + const endpointExceptionList = await soClient.find({ + type: EXCEPTION_LIST_NAMESPACE_AGNOSTIC, + filter: `${EXCEPTION_LIST_NAMESPACE_AGNOSTIC}.attributes.list_id: ${ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id}`, + }); - [REF_DATA_KEYS.spaceAwarenessResponseActionsMigration]: - (): ReferenceDataSavedObject => ({ - id: REF_DATA_KEYS.spaceAwarenessResponseActionsMigration, - owner: 'EDR', - type: 'MIGRATION', - metadata: { - started: new Date().toISOString(), - finished: '', - status: 'not-started', - data: {}, - }, - }), + // we should opt-in the user automatically if: + // - the FF is already enabled, AND + // - Endpoint exception list does not exist, i.e. it's a new deployment + shouldAutomaticallyOptIn = endpointExceptionList.total === 0; + } catch (error) { + throw wrapErrorIfNeeded( + error, + 'Failed to retrieve Endpoint exceptions list while determining default per-policy opt-in status.' + ); + } + } - [REF_DATA_KEYS.orphanResponseActionsSpace]: - (): ReferenceDataSavedObject => ({ - id: REF_DATA_KEYS.orphanResponseActionsSpace, - owner: 'EDR', - type: 'RESPONSE-ACTIONS', - metadata: { spaceId: '' }, - }), + if (shouldAutomaticallyOptIn) { + return { + id: REF_DATA_KEYS.endpointExceptionsPerPolicyOptInStatus, + owner: 'EDR', + type: 'OPT-IN-STATUS', + metadata: { + status: true, + reason: 'newDeployment', + user: 'automatic-opt-in', + timestamp: new Date().toISOString(), + }, + }; + } else { + return { + id: REF_DATA_KEYS.endpointExceptionsPerPolicyOptInStatus, + owner: 'EDR', + type: 'OPT-IN-STATUS', + metadata: { status: false }, + }; + } + }, }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/lib/reference_data/helpers.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/lib/reference_data/helpers.ts new file mode 100644 index 0000000000000..c3f658c0b933a --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/lib/reference_data/helpers.ts @@ -0,0 +1,61 @@ +/* + * 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 type { SavedObjectsClientContract } from '@kbn/core/server'; +import type { Logger } from '@kbn/logging'; +import type { ExperimentalFeatures } from '../../../../common'; +import { ReferenceDataClient } from './reference_data_client'; +import { REF_DATA_KEYS } from './constants'; +import type { OptInStatusMetadata } from './types'; +import { stringify } from '../../utils/stringify'; + +/** + * Reads the Endpoint Exceptions per-policy opt-in status from Reference Data. + * + * @param soClient soClient with write access to REFERENCE_DATA_SAVED_OBJECT_TYPE + * @param experimentalFeatures + * @param logger + */ +export const getIsEndpointExceptionsPerPolicyEnabled = async ( + soClient: SavedObjectsClientContract, + experimentalFeatures: ExperimentalFeatures, + logger: Logger +): Promise => { + const referenceDataClient = new ReferenceDataClient(soClient, experimentalFeatures, logger); + + const optInStatus = await referenceDataClient.get( + REF_DATA_KEYS.endpointExceptionsPerPolicyOptInStatus + ); + + return optInStatus.metadata.status; +}; + +/** + * Triggers the underlying automatic functionality to set the default value for + * Endpoint Exceptions per-policy opt-in. + * It has to be called during plugin.start() to ensure that the data used to decide + * whether opt-in should be enabled or not is not initialized yet on new deployments. + * + * @param soClient soClient with write access to REFERENCE_DATA_SAVED_OBJECT_TYPE + * @param experimentalFeatures + * @param logger + */ +export const initializeEndpointExceptionsPerPolicyOptInStatus = async ( + soClient: SavedObjectsClientContract, + experimentalFeatures: ExperimentalFeatures, + logger: Logger +): Promise => { + const referenceDataClient = new ReferenceDataClient(soClient, experimentalFeatures, logger); + + const result = await referenceDataClient.get( + REF_DATA_KEYS.endpointExceptionsPerPolicyOptInStatus + ); + + logger.debug( + `Initialized Endpoint Exceptions per-policy opt-in status to: ${stringify(result.metadata)}` + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/lib/reference_data/index.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/lib/reference_data/index.ts index d0a501dae459d..ae235253fec5e 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/lib/reference_data/index.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/lib/reference_data/index.ts @@ -9,3 +9,4 @@ export * from './saved_objects'; export * from './constants'; export * from './reference_data_client'; export type * from './types'; +export * from './helpers'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/lib/reference_data/mocks.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/lib/reference_data/mocks.ts index 4945bc937cdd7..2e5d6ace0791c 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/lib/reference_data/mocks.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/lib/reference_data/mocks.ts @@ -10,6 +10,7 @@ import { merge } from 'lodash'; import type { DeeplyMockedKeys } from '@kbn/utility-types-jest'; import type { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks'; import type { SavedObject, SavedObjectsUpdateResponse } from '@kbn/core/server'; +import type { ExperimentalFeatures } from '../../../../common'; import type { ReferenceDataClientInterface, ReferenceDataSavedObject } from './types'; import { REF_DATA_KEY_INITIAL_VALUE, @@ -71,7 +72,10 @@ const applyMocksToSoClient = (soClientMock: ReturnType { let soClientMock: ReturnType; @@ -25,7 +26,7 @@ describe('Reference Data Client', () => { const logger = loggingSystemMock.createLogger(); soClientMock = savedObjectsClientMock.create(); - refDataClient = new ReferenceDataClient(soClientMock, logger); + refDataClient = new ReferenceDataClient(soClientMock, allowedExperimentalValues, logger); referenceDataMocks.applyMocksToSoClient(soClientMock); }); @@ -89,8 +90,9 @@ describe('Reference Data Client', () => { describe('#update() method', () => { it('should update reference data item', async () => { - const update = - REF_DATA_KEY_INITIAL_VALUE[REF_DATA_KEYS.spaceAwarenessResponseActionsMigration](); + const update = await REF_DATA_KEY_INITIAL_VALUE[ + REF_DATA_KEYS.spaceAwarenessResponseActionsMigration + ](soClientMock, allowedExperimentalValues); await expect( refDataClient.update(REF_DATA_KEYS.spaceAwarenessResponseActionsMigration, update) @@ -98,8 +100,9 @@ describe('Reference Data Client', () => { }); it('should throw an error is update has an `id` that differs from the ref. data key', async () => { - const update = - REF_DATA_KEY_INITIAL_VALUE[REF_DATA_KEYS.spaceAwarenessResponseActionsMigration](); + const update = await REF_DATA_KEY_INITIAL_VALUE[ + REF_DATA_KEYS.spaceAwarenessResponseActionsMigration + ](soClientMock, allowedExperimentalValues); update.id = 'some-other-id' as unknown as ReferenceDataItemKey; await expect( diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/lib/reference_data/reference_data_client.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/lib/reference_data/reference_data_client.ts index 4e99b1061eb0a..7c970426f559d 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/lib/reference_data/reference_data_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/lib/reference_data/reference_data_client.ts @@ -8,6 +8,7 @@ import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server'; import type { Logger } from '@kbn/logging'; +import type { ExperimentalFeatures } from '../../../../common'; import { EndpointError } from '../../../../common/endpoint/errors'; import type { ReferenceDataClientInterface, @@ -15,7 +16,6 @@ import type { ReferenceDataSavedObject, } from './types'; import { REF_DATA_KEY_INITIAL_VALUE, REFERENCE_DATA_SAVED_OBJECT_TYPE } from './constants'; -import { stringify } from '../../utils/stringify'; import { catchAndWrapError, wrapErrorIfNeeded } from '../../utils'; /** @@ -25,6 +25,7 @@ import { catchAndWrapError, wrapErrorIfNeeded } from '../../utils'; export class ReferenceDataClient implements ReferenceDataClientInterface { constructor( protected readonly soClient: SavedObjectsClientContract, + protected readonly experimentalFeatures: ExperimentalFeatures, protected readonly logger: Logger ) {} @@ -64,7 +65,7 @@ export class ReferenceDataClient implements ReferenceDataClientInterface { return soClient .get>(REFERENCE_DATA_SAVED_OBJECT_TYPE, refDataKey) .then((response) => { - logger.debug(`Retrieved [${refDataKey}]\n${stringify(response)}`); + logger.debug(`Retrieved [${refDataKey}]`); return response.attributes; }) .catch(async (err) => { @@ -72,7 +73,10 @@ export class ReferenceDataClient implements ReferenceDataClientInterface { if (REF_DATA_KEY_INITIAL_VALUE[refDataKey]) { return this.create( refDataKey, - REF_DATA_KEY_INITIAL_VALUE[refDataKey]() as ReferenceDataSavedObject + (await REF_DATA_KEY_INITIAL_VALUE[refDataKey]( + soClient, + this.experimentalFeatures + )) as ReferenceDataSavedObject ); } diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/lib/reference_data/types.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/lib/reference_data/types.ts index 50f4f1b5ac16d..d03619596f9d9 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/lib/reference_data/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/lib/reference_data/types.ts @@ -5,17 +5,15 @@ * 2.0. */ +import type { REF_DATA_KEYS } from './constants'; + /** A union of all valid `owner` values for reference data entries */ export type ReferenceDataOwner = 'EDR'; /** * List of allowed `key`'s of reference data items. - * Use the `REF_DATA_KEYS` object from `./constants` to reference these in code. */ -export type ReferenceDataItemKey = - | 'SPACE-AWARENESS-ARTIFACT-MIGRATION' - | 'SPACE-AWARENESS-RESPONSE-ACTIONS-MIGRATION' - | 'ORPHAN-RESPONSE-ACTIONS-SPACE'; +export type ReferenceDataItemKey = (typeof REF_DATA_KEYS)[keyof typeof REF_DATA_KEYS]; export interface ReferenceDataSavedObject { id: ReferenceDataItemKey; @@ -54,3 +52,10 @@ export interface MigrationMetadata { export interface OrphanResponseActionsMetadata { spaceId: string; } + +export interface OptInStatusMetadata { + status: boolean; + reason?: 'newDeployment' | 'userOptedIn'; + user?: string; + timestamp?: string; +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/mocks/mocks.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/mocks/mocks.ts index 4477dfef098fe..265be9daa3d00 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/mocks/mocks.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/mocks/mocks.ts @@ -172,6 +172,7 @@ export const createMockEndpointAppContextService = ( getServerConfigValue: jest.fn(), getScriptsLibraryClient: jest.fn().mockReturnValue(scriptsClient), getAgentBuilder: jest.fn(), + isEndpointExceptionsPerPolicyEnabled: jest.fn().mockResolvedValue(true), } as Omit< jest.Mocked, | 'config' diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/orphan_actions_space_handler.test.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/orphan_actions_space_handler.test.ts index 335b83513f4b5..5ba990acd95af 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/orphan_actions_space_handler.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/orphan_actions_space_handler.test.ts @@ -10,10 +10,11 @@ import { createHttpApiTestSetupMock } from '../../mocks'; import type { UpdateOrphanActionsSpaceBody } from './orphan_actions_space_handler'; import { registerOrphanActionsSpaceRoute } from './orphan_actions_space_handler'; import { ORPHAN_ACTIONS_SPACE_ROUTE } from '../../../../common/endpoint/constants'; -import type { RequestHandler } from '@kbn/core/server'; +import type { RequestHandler, SavedObjectsClientContract } from '@kbn/core/server'; import type { DeeplyMockedKeys } from '@kbn/utility-types-jest'; import type { ReferenceDataClientInterface } from '../../lib/reference_data'; import { REF_DATA_KEY_INITIAL_VALUE, REF_DATA_KEYS } from '../../lib/reference_data'; +import type { ExperimentalFeatures } from '../../../../common'; describe('Orphan response action APIs', () => { let endpointServiceMock: HttpApiTestSetupMock['endpointAppContextMock']['service']; @@ -45,7 +46,10 @@ describe('Orphan response action APIs', () => { const refDataClient = endpointServiceMock.getReferenceDataClient() as DeeplyMockedKeys; refDataClient.get.mockResolvedValue( - REF_DATA_KEY_INITIAL_VALUE[REF_DATA_KEYS.orphanResponseActionsSpace]() + await REF_DATA_KEY_INITIAL_VALUE[REF_DATA_KEYS.orphanResponseActionsSpace]( + {} as SavedObjectsClientContract, + {} as ExperimentalFeatures + ) ); }); diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/endpoint_exceptions_per_policy_opt_in/endpoint_exceptions_per_policy_opt_in.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/endpoint_exceptions_per_policy_opt_in/endpoint_exceptions_per_policy_opt_in.ts new file mode 100644 index 0000000000000..e106dd010936e --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/endpoint_exceptions_per_policy_opt_in/endpoint_exceptions_per_policy_opt_in.ts @@ -0,0 +1,133 @@ +/* + * 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 { ReservedPrivilegesSet, type RequestHandler } from '@kbn/core/server'; +import type { GetEndpointExceptionsPerPolicyOptInResponse } from '../../../../common/api/endpoint/endpoint_exceptions_per_policy_opt_in/endpoint_exceptions_per_policy_opt_in.gen'; +import type { + SecuritySolutionPluginRouter, + SecuritySolutionRequestHandlerContext, +} from '../../../types'; +import type { EndpointAppContext } from '../../types'; +import { ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE } from '../../../../common/endpoint/constants'; +import type { EndpointAppContextService } from '../../endpoint_app_context_services'; +import { errorHandler } from '../error_handler'; +import type { OptInStatusMetadata } from '../../lib/reference_data'; +import { REF_DATA_KEYS } from '../../lib/reference_data'; +import { withEndpointAuthz } from '../with_endpoint_authz'; + +export const getOptInToPerPolicyEndpointExceptionsPOSTHandler = ( + endpointAppServices: EndpointAppContextService +): RequestHandler => { + const logger = endpointAppServices.createLogger('endpointExceptionsPerPolicyOptInHandler'); + + return async (context, req, res) => { + try { + const coreContext = await context.core; + const user = coreContext.security.authc.getCurrentUser(); + const referenceDataClient = endpointAppServices.getReferenceDataClient(); + + const currentOptInStatus = await referenceDataClient.get( + REF_DATA_KEYS.endpointExceptionsPerPolicyOptInStatus + ); + + currentOptInStatus.metadata = { + status: true, + reason: 'userOptedIn', + user: user?.username ?? 'unknown', + timestamp: new Date().toISOString(), + }; + + await referenceDataClient.update( + REF_DATA_KEYS.endpointExceptionsPerPolicyOptInStatus, + currentOptInStatus + ); + + logger.info('Endpoint Exceptions per policy opt-in successful'); + + return res.ok(); + } catch (err) { + return errorHandler(logger, res, err); + } + }; +}; + +export const getOptInToPerPolicyEndpointExceptionsGETHandler = ( + endpointAppServices: EndpointAppContextService +): RequestHandler => { + const logger = endpointAppServices.createLogger('endpointExceptionsPerPolicyOptInHandler'); + + return async (context, req, res) => { + try { + const referenceDataClient = endpointAppServices.getReferenceDataClient(); + + const currentOptInStatus = await referenceDataClient.get( + REF_DATA_KEYS.endpointExceptionsPerPolicyOptInStatus + ); + + const body: GetEndpointExceptionsPerPolicyOptInResponse = { + status: currentOptInStatus.metadata.status, + reason: currentOptInStatus.metadata.reason, + }; + + return res.ok({ body }); + } catch (err) { + return errorHandler(logger, res, err); + } + }; +}; + +export const registerEndpointExceptionsPerPolicyOptInRoute = ( + router: SecuritySolutionPluginRouter, + endpointContext: EndpointAppContext +) => { + const logger = endpointContext.logFactory.get('endpointExceptionsPerPolicyOptInHandler'); + + router.versioned + .post({ + access: 'internal', + path: ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE, + security: { + authz: { requiredPrivileges: [ReservedPrivilegesSet.superuser] }, + }, + }) + .addVersion( + { + version: '1', + validate: {}, + }, + // todo: would be better to add `canWriteAdminData` to `withEndpointAuthz`, instead of + // using the ReservedPrivilegesSet.superuser above, + // so we have a single source of truth regarding authz, but the role names in the serverless API test + // are not passed through the authz service, which makes the test fail, so for now rather have the test pass. + withEndpointAuthz( + // hiding behind Platinum+ license + { all: ['canCreateArtifactsByPolicy'] }, + logger, + getOptInToPerPolicyEndpointExceptionsPOSTHandler(endpointContext.service) + ) + ); + + router.versioned + .get({ + access: 'internal', + path: ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE, + security: { + authz: { requiredPrivileges: ['securitySolution'] }, + }, + }) + .addVersion( + { + version: '1', + validate: {}, + }, + withEndpointAuthz( + { all: ['canReadEndpointExceptions'] }, + logger, + getOptInToPerPolicyEndpointExceptionsGETHandler(endpointContext.service) + ) + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/endpoint_exceptions_per_policy_opt_in/index.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/endpoint_exceptions_per_policy_opt_in/index.ts new file mode 100644 index 0000000000000..2454d695de275 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/endpoint_exceptions_per_policy_opt_in/index.ts @@ -0,0 +1,19 @@ +/* + * 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 type { SecuritySolutionPluginRouter } from '../../../types'; +import type { EndpointAppContext } from '../../types'; +import { registerEndpointExceptionsPerPolicyOptInRoute } from './endpoint_exceptions_per_policy_opt_in'; + +export const registerEndpointExceptionsRoutes = ( + router: SecuritySolutionPluginRouter, + endpointContext: EndpointAppContext +) => { + if (endpointContext.experimentalFeatures.endpointExceptionsMovedUnderManagement) { + registerEndpointExceptionsPerPolicyOptInRoute(router, endpointContext); + } +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/utils/fetch_action_request_by_id.test.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/utils/fetch_action_request_by_id.test.ts index 3bf768132c805..59eb509145c73 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/utils/fetch_action_request_by_id.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/utils/fetch_action_request_by_id.test.ts @@ -15,6 +15,8 @@ import { EndpointActionGenerator } from '../../../../../common/endpoint/data_gen import { set } from '@kbn/safer-lodash-set'; import { ALLOWED_ACTION_REQUEST_TAGS } from '../constants'; import { REF_DATA_KEY_INITIAL_VALUE, REF_DATA_KEYS } from '../../../lib/reference_data'; +import type { SavedObjectsClientContract } from '@kbn/core/server'; +import type { ExperimentalFeatures } from '../../../../../common'; describe('fetchActionRequestById() utility', () => { let endpointServiceMock: ReturnType; @@ -75,7 +77,10 @@ describe('fetchActionRequestById() utility', () => { }); (endpointServiceMock.getReferenceDataClient().get as jest.Mock).mockResolvedValue( set( - REF_DATA_KEY_INITIAL_VALUE[REF_DATA_KEYS.orphanResponseActionsSpace](), + await REF_DATA_KEY_INITIAL_VALUE[REF_DATA_KEYS.orphanResponseActionsSpace]( + {} as SavedObjectsClientContract, + {} as ExperimentalFeatures + ), 'metadata.spaceId', 'foo' ) @@ -98,7 +103,10 @@ describe('fetchActionRequestById() utility', () => { }); (endpointServiceMock.getReferenceDataClient().get as jest.Mock).mockResolvedValue( set( - REF_DATA_KEY_INITIAL_VALUE[REF_DATA_KEYS.orphanResponseActionsSpace](), + await REF_DATA_KEY_INITIAL_VALUE[REF_DATA_KEYS.orphanResponseActionsSpace]( + {} as SavedObjectsClientContract, + {} as ExperimentalFeatures + ), 'metadata.spaceId', 'bar' ) diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/utils/fetch_action_requests.test.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/utils/fetch_action_requests.test.ts index f58eb890481fa..bf6df792d6ade 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/utils/fetch_action_requests.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/utils/fetch_action_requests.test.ts @@ -15,6 +15,8 @@ import { createMockEndpointAppContextService } from '../../../mocks'; import { REF_DATA_KEY_INITIAL_VALUE, REF_DATA_KEYS } from '../../../lib/reference_data'; import { set } from '@kbn/safer-lodash-set'; import { ALLOWED_ACTION_REQUEST_TAGS } from '../constants'; +import type { SavedObjectsClientContract } from '@kbn/core/server'; +import type { ExperimentalFeatures } from '../../../../../common'; describe('fetchActionRequests()', () => { let esClientMock: ElasticsearchClientMock; @@ -440,12 +442,12 @@ describe('fetchActionRequests()', () => { }); it('should include search filter for deleted integration policy tag when ref. data has one defined', async () => { + const initialValue = await REF_DATA_KEY_INITIAL_VALUE[ + REF_DATA_KEYS.orphanResponseActionsSpace + ]({} as SavedObjectsClientContract, {} as ExperimentalFeatures); + (fetchOptions.endpointService.getReferenceDataClient().get as jest.Mock).mockResolvedValue( - set( - REF_DATA_KEY_INITIAL_VALUE[REF_DATA_KEYS.orphanResponseActionsSpace](), - 'metadata.spaceId', - 'bar' - ) + set(initialValue, 'metadata.spaceId', 'bar') ); fetchOptions.spaceId = 'bar'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.mock.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.mock.ts index 6f03f5ba661bd..49544ceda38ee 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.mock.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.mock.ts @@ -151,7 +151,7 @@ export const getManifestManagerMock = ( context.exceptionListClient.findExceptionListItem = jest .fn() .mockRejectedValue(new Error('unexpected thing happened')); - return super.buildExceptionListArtifacts([]); + return super.buildExceptionListArtifacts([], false); case ManifestManagerMockType.NormalFlow: return getMockArtifactsWithDiff(); } diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts index f9d69b6385d5f..c59d62d62b345 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts @@ -50,6 +50,14 @@ import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server'; import { createLicenseServiceMock } from '../../../../../common/license/mocks'; import { GLOBAL_ARTIFACT_TAG } from '../../../../../common/endpoint/service/artifacts'; import { buildPerPolicyTag } from '../../../../../common/endpoint/service/artifacts/utils'; +import { getIsEndpointExceptionsPerPolicyEnabled } from '../../../lib/reference_data'; + +jest.mock('../../../lib/reference_data'); + +const mockedGetIsEndpointExceptionsPerPolicyEnabled = + getIsEndpointExceptionsPerPolicyEnabled as jest.MockedFunction< + typeof getIsEndpointExceptionsPerPolicyEnabled + >; const getArtifactObject = (artifact: InternalArtifactSchema) => JSON.parse(Buffer.from(artifact.body!, 'base64').toString()); @@ -131,6 +139,11 @@ describe('ManifestManager', () => { defaultFeatures = allowedExperimentalValues; }); + beforeEach(() => { + jest.clearAllMocks(); + mockedGetIsEndpointExceptionsPerPolicyEnabled.mockResolvedValue(false); + }); + describe('getLastComputedManifest from Unified Manifest SO', () => { const mockGetAllUnifiedManifestsSOFromCache = jest.fn().mockImplementation(() => [ { @@ -1258,12 +1271,9 @@ describe('ManifestManager', () => { ]); }); - describe('when Endpoint Exceptions can only be global - feature flag is disabled', () => { + describe('when Endpoint Exceptions can only be global - user has not opted in', () => { beforeEach(() => { - context.experimentalFeatures = { - ...context.experimentalFeatures, - endpointExceptionsMovedUnderManagement: false, - }; + mockedGetIsEndpointExceptionsPerPolicyEnabled.mockResolvedValue(false); manifestManager = new ManifestManager(context); }); @@ -1290,15 +1300,14 @@ describe('ManifestManager', () => { defaultFeatures ), }); + + expect(mockedGetIsEndpointExceptionsPerPolicyEnabled).toHaveBeenCalledTimes(1); }); }); describe('when Endpoint Exceptions can be per-policy - feature flag is enabled', () => { beforeEach(() => { - context.experimentalFeatures = { - ...context.experimentalFeatures, - endpointExceptionsMovedUnderManagement: true, - }; + mockedGetIsEndpointExceptionsPerPolicyEnabled.mockResolvedValue(true); manifestManager = new ManifestManager(context); }); @@ -1336,6 +1345,8 @@ describe('ManifestManager', () => { defaultFeatures ), }); + + expect(mockedGetIsEndpointExceptionsPerPolicyEnabled).toHaveBeenCalledTimes(1); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts index 4fc4b9a21080e..4359c38b87095 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts @@ -60,6 +60,7 @@ import { InvalidInternalManifestError } from '../errors'; import { wrapErrorIfNeeded } from '../../../utils'; import { EndpointError } from '../../../../../common/endpoint/errors'; import type { SavedObjectsClientFactory } from '../../saved_objects'; +import { getIsEndpointExceptionsPerPolicyEnabled } from '../../../lib/reference_data'; interface ArtifactsBuildResult { defaultArtifacts: InternalArtifactCompleteSchema[]; @@ -194,6 +195,7 @@ export class ManifestManager { policyId, schemaVersion, exceptionItemDecorator, + isEndpointExceptionsPerPolicyEnabled, }: { elClient: ExceptionListClient; listId: ArtifactListId; @@ -201,6 +203,7 @@ export class ManifestManager { policyId?: string; schemaVersion: string; exceptionItemDecorator?: (item: ExceptionListItemSchema) => ExceptionListItemSchema; + isEndpointExceptionsPerPolicyEnabled?: boolean; }): Promise { if (!this.cachedExceptionsListsByOs.has(`${listId}-${os}`)) { let itemsByListId: ExceptionListItemSchema[] = []; @@ -232,7 +235,7 @@ export class ManifestManager { let exceptions: ExceptionListItemSchema[]; - if (this.experimentalFeatures.endpointExceptionsMovedUnderManagement) { + if (isEndpointExceptionsPerPolicyEnabled) { // with the feature enabled, we do not make an 'exception' with endpoint exceptions - it's filtered per-policy exceptions = allExceptionsByListId.filter(filter); } else { @@ -256,9 +259,11 @@ export class ManifestManager { os, policyId, exceptionItemDecorator, + isEndpointExceptionsPerPolicyEnabled, }: { os: string; policyId?: string; + isEndpointExceptionsPerPolicyEnabled?: boolean; } & BuildArtifactsForOsOptions): Promise { return buildArtifact( await this.getCachedExceptions({ @@ -268,6 +273,7 @@ export class ManifestManager { policyId, listId, exceptionItemDecorator, + isEndpointExceptionsPerPolicyEnabled, }), this.schemaVersion, os, @@ -283,14 +289,20 @@ export class ManifestManager { protected async buildArtifactsByPolicy( allPolicyIds: string[], supportedOSs: string[], - osOptions: BuildArtifactsForOsOptions + osOptions: BuildArtifactsForOsOptions, + isEndpointExceptionsPerPolicyEnabled?: boolean ): Promise> { const policySpecificArtifacts: Record = {}; for (const policyId of allPolicyIds) for (const os of supportedOSs) { policySpecificArtifacts[policyId] = policySpecificArtifacts[policyId] || []; policySpecificArtifacts[policyId].push( - await this.buildArtifactsForOs({ os, policyId, ...osOptions }) + await this.buildArtifactsForOs({ + os, + policyId, + isEndpointExceptionsPerPolicyEnabled, + ...osOptions, + }) ); } @@ -305,7 +317,8 @@ export class ManifestManager { * @throws Throws/rejects if there are errors building the list. */ protected async buildExceptionListArtifacts( - allPolicyIds: string[] + allPolicyIds: string[], + isEndpointExceptionsPerPolicyEnabled: boolean ): Promise { const defaultArtifacts: InternalArtifactCompleteSchema[] = []; @@ -332,16 +345,23 @@ export class ManifestManager { }; for (const os of ArtifactConstants.SUPPORTED_ENDPOINT_EXCEPTIONS_OPERATING_SYSTEMS) { - defaultArtifacts.push(await this.buildArtifactsForOs({ os, ...buildArtifactsForOsOptions })); + defaultArtifacts.push( + await this.buildArtifactsForOs({ + os, + isEndpointExceptionsPerPolicyEnabled, + ...buildArtifactsForOsOptions, + }) + ); } let policySpecificArtifacts: Record = {}; - if (this.experimentalFeatures.endpointExceptionsMovedUnderManagement) { + if (isEndpointExceptionsPerPolicyEnabled) { policySpecificArtifacts = await this.buildArtifactsByPolicy( allPolicyIds, ArtifactConstants.SUPPORTED_ENDPOINT_EXCEPTIONS_OPERATING_SYSTEMS, - buildArtifactsForOsOptions + buildArtifactsForOsOptions, + isEndpointExceptionsPerPolicyEnabled ); } else { allPolicyIds.forEach((policyId) => { @@ -696,9 +716,15 @@ export class ManifestManager { public async buildNewManifest( baselineManifest: Manifest = ManifestManager.createDefaultManifest(this.schemaVersion) ): Promise { + const isEndpointExceptionsPerPolicyEnabled = await getIsEndpointExceptionsPerPolicyEnabled( + this.savedObjectsClientFactory.createInternalScopedSoClient({ readonly: false }), + this.experimentalFeatures, + this.logger + ); + const allPolicyIds = await this.listEndpointPolicyIds(); const results = await Promise.all([ - this.buildExceptionListArtifacts(allPolicyIds), + this.buildExceptionListArtifacts(allPolicyIds, isEndpointExceptionsPerPolicyEnabled), this.buildTrustedAppsArtifacts(allPolicyIds), this.buildEventFiltersArtifacts(allPolicyIds), this.buildHostIsolationExceptionsArtifacts(allPolicyIds), diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts index 1b8f8385bdaff..f95171b11f917 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts @@ -108,6 +108,7 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = eventsTelemetry, licensing, scheduleNotificationResponseActionsService, + endpointAppContextService, }) => (type) => { const { alertIgnoreFields: ignoreFields, alertMergeStrategy: mergeStrategy } = config; @@ -343,7 +344,7 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = client: exceptionsClient, lists: params.exceptionsList, shouldFilterOutEndpointExceptions: - experimentalFeatures.endpointExceptionsMovedUnderManagement, + await endpointAppContextService.isEndpointExceptionsPerPolicyEnabled(), }); const alertTimestampOverride = isPreview ? startedAt : undefined; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts index 2beefd3fc3d0a..de72f1086d62f 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts @@ -19,6 +19,7 @@ import { QUERY_RULE_TYPE_ID } from '@kbn/securitysolution-rules'; import { docLinksServiceMock } from '@kbn/core/server/mocks'; import { IndexPatternsFetcher } from '@kbn/data-views-plugin/server'; import { hasTimestampFields } from '../utils/utils'; +import { createMockEndpointAppContextService } from '../../../../endpoint/mocks'; jest.mock('@kbn/data-views-plugin/server', () => ({ ...jest.requireActual('@kbn/data-views-plugin/server'), @@ -79,6 +80,7 @@ describe('Custom Query Alerts', () => { eventsTelemetry, licensing, scheduleNotificationResponseActionsService: () => null, + endpointAppContextService: createMockEndpointAppContextService(), }); afterEach(() => { diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts index 565c94232a1e2..1827bf8eb3365 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts @@ -32,6 +32,7 @@ import type { Filter } from '@kbn/es-query'; import type { LicensingPluginSetup } from '@kbn/licensing-plugin/server'; import type { DocLinksServiceSetup } from '@kbn/core/server'; +import type { EndpointAppContextService } from '../../../endpoint/endpoint_app_context_services'; import type { RulePreviewLoggedRequest } from '../../../../common/api/detection_engine/rule_preview/rule_preview.gen'; import type { RuleResponseAction } from '../../../../common/api/detection_engine/model/rule_response_actions'; import type { ConfigType } from '../../../config'; @@ -160,6 +161,7 @@ export interface CreateSecurityRuleTypeWrapperProps { eventsTelemetry: ITelemetryEventsSender | undefined; licensing: LicensingPluginSetup; scheduleNotificationResponseActionsService: ScheduleNotificationResponseActionsService; + endpointAppContextService: EndpointAppContextService; } export type CreateSecurityRuleTypeWrapper = ( diff --git a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_create_handler.ts b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_create_handler.ts index 9174e5dbbb79f..06caca49af561 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_create_handler.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_create_handler.ts @@ -92,11 +92,11 @@ export const getExceptionsPreCreateItemHandler = ( ); validatedItem = await endpointExceptionValidator.validatePreCreateItem(data); - if (!endpointAppContext.experimentalFeatures.endpointExceptionsMovedUnderManagement) { + if (!(await endpointAppContext.isEndpointExceptionsPerPolicyEnabled())) { // If artifact does not have an assignment tag, then add it now. This is in preparation for // adding per-policy support to Endpoint Exceptions as well as to support space awareness. // - // Only added when the FF is disabled, as its enabled state indicates per-policy support. + // Only added when the user has not opted in to per-policy Endpoint Exceptions. if (!hasGlobalOrPerPolicyTag(validatedItem)) { validatedItem.tags = validatedItem.tags ?? []; validatedItem.tags.push(GLOBAL_ARTIFACT_TAG); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_import_handler.ts b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_import_handler.ts index 0cd0bc18bae2a..a04d93f1a96b3 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_import_handler.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_import_handler.ts @@ -23,6 +23,7 @@ import type { import { buildSpaceOwnerIdTag, hasArtifactOwnerSpaceId, + isArtifactGlobal, } from '../../../../common/endpoint/service/artifacts/utils'; import { stringify } from '../../../endpoint/utils/stringify'; import type { EndpointAppContextService } from '../../../endpoint/endpoint_app_context_services'; @@ -60,7 +61,13 @@ export const getExceptionsPreImportHandler = ( const logger = endpointAppContext.createLogger('listsPreImportExtensionPoint'); validateCanEndpointArtifactsBeImported(data, endpointAppContext.experimentalFeatures); - provideSpaceAwarenessCompatibilityForOldEndpointExceptions(data, logger); + const spaceId = getSpaceId(endpointAppContext, request); + await provideSpaceAwarenessCompatibilityForOldEndpointExceptions( + data, + endpointAppContext, + spaceId, + logger + ); if (!endpointAppContext.experimentalFeatures.endpointExceptionsMovedUnderManagement) { return { data, overwrite }; @@ -131,10 +138,16 @@ export const getExceptionsPreImportHandler = ( request ); await endpointExceptionValidator.validatePreImport(data); + + if (!(await endpointAppContext.isEndpointExceptionsPerPolicyEnabled())) { + // if user hasn't opted in to per policy endpoint exceptions, we're skipping the + // per-policy / space awareness magic below. + // note: owner space ID is added in provideSpaceAwarenessCompatibilityForOldEndpointExceptions function. + return { data, overwrite }; + } } // --- Below are operations to prepare the imported data --- - const spaceId = getSpaceId(endpointAppContext, request); for (const item of data.items) { if (!(item instanceof Error)) { addOwnerSpaceIdTagToItem(item, spaceId); @@ -202,22 +215,35 @@ const validateCanEndpointArtifactsBeImported = ( * Exceptions continue to be global only in v9.1, we add the global tag to them here if it is * missing */ -const provideSpaceAwarenessCompatibilityForOldEndpointExceptions = ( +const provideSpaceAwarenessCompatibilityForOldEndpointExceptions = async ( data: PromiseFromStreams, + endpointAppContext: EndpointAppContextService, + spaceId: string, logger: Logger -): void => { +): Promise => { const adjustedImportItems: PromiseFromStreams['items'] = []; + const isEndpointExceptionPerPolicyEnabled = + await endpointAppContext.isEndpointExceptionsPerPolicyEnabled(); + for (const item of data.items) { if ( !(item instanceof Error) && item.list_id === ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id && - item.namespace_type === 'agnostic' && - !hasArtifactOwnerSpaceId(item) + item.namespace_type === 'agnostic' ) { - item.tags = item.tags ?? []; - item.tags.push(GLOBAL_ARTIFACT_TAG); - adjustedImportItems.push(item); + if ( + // before per-policy opt-in: every endpoint exception should be global + (!isEndpointExceptionPerPolicyEnabled && !isArtifactGlobal(item)) || + // after per-policy opt-in: no space-owner ID means pre 9.1 => let's add the global tag + (isEndpointExceptionPerPolicyEnabled && !hasArtifactOwnerSpaceId(item)) + ) { + item.tags = item.tags ?? []; + item.tags.push(GLOBAL_ARTIFACT_TAG); + addOwnerSpaceIdTagToItem(item, spaceId); + + adjustedImportItems.push(item); + } } } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_update_handler.ts b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_update_handler.ts index 837128ac65484..a124ed45c35dc 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_update_handler.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_update_handler.ts @@ -131,11 +131,11 @@ export const getExceptionsPreUpdateItemHandler = ( currentSavedItem ); - if (!endpointAppContextService.experimentalFeatures.endpointExceptionsMovedUnderManagement) { + if (!(await endpointAppContextService.isEndpointExceptionsPerPolicyEnabled())) { // If artifact does not have an assignment tag, then add it now. This is in preparation for - // adding per-policy support to Endpoint Exceptions as well as to support space awareness + // adding per-policy support to Endpoint Exceptions as well as to support space awareness. // - // Only added when the FF is disabled, as its enabled state indicates per-policy support. + // Only added when the user has not opted in to per-policy Endpoint Exceptions. if (!hasGlobalOrPerPolicyTag(validatedItem)) { validatedItem.tags = validatedItem.tags ?? []; validatedItem.tags.push(GLOBAL_ARTIFACT_TAG); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/endpoint_exceptions_validator.ts b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/endpoint_exceptions_validator.ts index 96957b3509f01..76248351499e7 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/endpoint_exceptions_validator.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lists_integration/endpoint/validators/endpoint_exceptions_validator.ts @@ -28,9 +28,9 @@ export class EndpointExceptionsValidator extends BaseValidator { protected async validateHasWritePrivilege(): Promise { await this.validateHasPrivilege('canWriteEndpointExceptions'); - if (!this.endpointAppContext.experimentalFeatures.endpointExceptionsMovedUnderManagement) { - // With disabled FF, Endpoint Exceptions are ONLY global, so we need to make sure the user - // also has the new Global Artifacts privilege + if (!(await this.endpointAppContext.isEndpointExceptionsPerPolicyEnabled())) { + // Without the user opting in, Endpoint Exceptions are ONLY global, + // so we need to make sure the user also has the new Global Artifacts privilege try { await this.validateHasPrivilege('canManageGlobalArtifacts'); } catch (error) { @@ -60,7 +60,7 @@ export class EndpointExceptionsValidator extends BaseValidator { async validatePreCreateItem(item: CreateExceptionListItemOptions) { await this.validateHasWritePrivilege(); - if (this.endpointAppContext.experimentalFeatures.endpointExceptionsMovedUnderManagement) { + if (await this.endpointAppContext.isEndpointExceptionsPerPolicyEnabled()) { await this.validateCanCreateByPolicyArtifacts(item); await this.validateByPolicyItem(item); } @@ -79,7 +79,7 @@ export class EndpointExceptionsValidator extends BaseValidator { await this.validateHasWritePrivilege(); - if (this.endpointAppContext.experimentalFeatures.endpointExceptionsMovedUnderManagement) { + if (await this.endpointAppContext.isEndpointExceptionsPerPolicyEnabled()) { try { await this.validateCanCreateByPolicyArtifacts(updatedItem); } catch (noByPolicyAuthzError) { diff --git a/x-pack/solutions/security/plugins/security_solution/server/plugin.ts b/x-pack/solutions/security/plugins/security_solution/server/plugin.ts index 796209672bd3e..cdcba96184f90 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/plugin.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/plugin.ts @@ -162,6 +162,11 @@ import type { TrialCompanionRoutesDeps } from './lib/trial_companion/types'; import { setupAlertsCapabilitiesSwitcher } from './lib/capabilities/alerts_capabilities_switcher'; import { securityAlertsProfileInitializer } from './lib/anonymization'; import { registerWatchlistMaintainer } from './lib/entity_analytics/watchlists/maintainer/register_watchlist_maintainer'; +import { registerEndpointExceptionsRoutes } from './endpoint/routes/endpoint_exceptions_per_policy_opt_in'; +import { + initializeEndpointExceptionsPerPolicyOptInStatus, + REFERENCE_DATA_SAVED_OBJECT_TYPE, +} from './endpoint/lib/reference_data'; export type { SetupPlugins, StartPlugins, PluginSetup, PluginStart } from './plugin_contract'; @@ -520,6 +525,7 @@ export class Plugin implements ISecuritySolutionPlugin { endpointAppContextService: this.endpointAppContextService, osqueryCreateActionService: plugins.osquery?.createActionService, }), + endpointAppContextService: this.endpointAppContextService, }; const securityRuleTypeWrapper = createSecurityRuleTypeWrapper(securityRuleTypeOptions); @@ -594,6 +600,7 @@ export class Plugin implements ISecuritySolutionPlugin { plugins.encryptedSavedObjects?.canEncrypt === true ); registerAgentRoutes(router, this.endpointContext); + registerEndpointExceptionsRoutes(router, this.endpointContext); registerScriptsLibraryRoutes(router, this.endpointContext); if (plugins.alerting != null) { @@ -759,6 +766,18 @@ export class Plugin implements ISecuritySolutionPlugin { ): SecuritySolutionPluginStart { const { config, logger, productFeaturesService } = this; + initializeEndpointExceptionsPerPolicyOptInStatus( + new SavedObjectsClient( + core.savedObjects.createInternalRepository([REFERENCE_DATA_SAVED_OBJECT_TYPE]) + ), + config.experimentalFeatures, + logger + ).catch((error) => { + this.logger.error( + `Error initializing Endpoint Exceptions per-policy opt-in status: ${error}` + ); + }); + this.ruleMonitoringService.start(core, plugins); const savedObjectsClient = new SavedObjectsClient( diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/artifact_import.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/artifact_import.ts index a1324c7ee6279..bf36e7c5b2593 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/artifact_import.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/artifact_import.ts @@ -36,6 +36,10 @@ import type { import { ensureSpaceIdExists } from '@kbn/security-solution-plugin/scripts/endpoint/common/spaces'; import { addSpaceIdToPath } from '@kbn/spaces-utils'; import type { ToolingLog } from '@kbn/tooling-log'; +import { + disablePerPolicyEndpointExceptions, + optInForPerPolicyEndpointExceptions, +} from '@kbn/security-solution-plugin/scripts/endpoint/common/endpoint_artifact_services'; import type { CustomRole } from '../../../../config/services/types'; import { ROLE } from '../../../../config/services/security_solution_edr_workflows_roles_users'; import type { FtrProviderContext } from '../../../../ftr_provider_context_edr_workflows'; @@ -105,11 +109,53 @@ export default function artifactImportAPIIntegrationTests({ getService }: FtrPro describe('@ess @serverless @skipInServerlessMKI Import Endpoint artifacts API', function () { const CURRENT_SPACE_ID = 'default'; const OTHER_SPACE_ID = 'other-space'; + const CURRENT_SPACE_OWNER_TAG = buildSpaceOwnerIdTag(CURRENT_SPACE_ID); + const OTHER_SPACE_OWNER_TAG = buildSpaceOwnerIdTag(OTHER_SPACE_ID); + let fleetEndpointPolicy: PolicyTestResourceInfo; let fleetEndpointPolicyOtherSpace: PolicyTestResourceInfo; let endpointOpsAnalystSupertest: TestAgent; + type SupertestContainer = Record< + (typeof ENDPOINT_ARTIFACT_LIST_IDS)[number], + Record<'none' | 'read' | 'all' | 'allWithGlobalArtifactManagementPrivilege', TestAgent> + >; + + const supertest: SupertestContainer = {} as SupertestContainer; + before(async () => { + for (const artifact of ENDPOINT_ARTIFACTS) { + supertest[artifact.listId] = { + none: await createSupertestWithCustomRole(`${artifact.listId}_none`, { + // user is authorized to use _import API in general, but missing artifact-specific privileges + [SECURITY_FEATURE_ID]: ['minimal_all'], + [RULES_FEATURE_ID]: ['all'], + }), + + read: await createSupertestWithCustomRole(`${artifact.listId}_read`, { + // user is authorized to use _import API in general, but missing artifact-specific privileges + [SECURITY_FEATURE_ID]: ['minimal_all', artifact.read], + [RULES_FEATURE_ID]: ['all'], + }), + + all: await createSupertestWithCustomRole(`${artifact.listId}_all`, { + // user is authorized for artifact import, but not rule exceptions import + [SECURITY_FEATURE_ID]: ['minimal_read', artifact.all], + }), + + allWithGlobalArtifactManagementPrivilege: await createSupertestWithCustomRole( + `${artifact.listId}_allWithGlobal`, + { + [SECURITY_FEATURE_ID]: [ + 'minimal_read', + artifact.all, + 'global_artifact_management_all', + ], + } + ), + }; + } + await ensureSpaceIdExists(kbnServer, OTHER_SPACE_ID, { log }); endpointOpsAnalystSupertest = await utils.createSuperTest(ROLE.endpoint_operations_analyst); @@ -131,56 +177,14 @@ export default function artifactImportAPIIntegrationTests({ getService }: FtrPro if (IS_ENDPOINT_EXCEPTION_MOVE_FF_ENABLED) { describe('Endpoint exceptions move feature flag enabled', () => { - const CURRENT_SPACE_OWNER_TAG = buildSpaceOwnerIdTag(CURRENT_SPACE_ID); - const OTHER_SPACE_OWNER_TAG = buildSpaceOwnerIdTag(OTHER_SPACE_ID); - - type SupertestContainer = Record< - (typeof ENDPOINT_ARTIFACT_LIST_IDS)[number], - Record<'none' | 'read' | 'all' | 'allWithGlobalArtifactManagementPrivilege', TestAgent> - >; - - const supertest: SupertestContainer = {} as SupertestContainer; - - before(async () => { - for (const artifact of ENDPOINT_ARTIFACTS) { - supertest[artifact.listId] = { - none: await createSupertestWithCustomRole(`${artifact.listId}_none`, { - // user is authorized to use _import API in general, but missing artifact-specific privileges - [SECURITY_FEATURE_ID]: ['minimal_all'], - [RULES_FEATURE_ID]: ['all'], - }), - - read: await createSupertestWithCustomRole(`${artifact.listId}_read`, { - // user is authorized to use _import API in general, but missing artifact-specific privileges - [SECURITY_FEATURE_ID]: ['minimal_all', artifact.read], - [RULES_FEATURE_ID]: ['all'], - }), - - all: await createSupertestWithCustomRole(`${artifact.listId}_all`, { - // user is authorized for artifact import, but not rule exceptions import - [SECURITY_FEATURE_ID]: ['minimal_read', artifact.all], - }), - - allWithGlobalArtifactManagementPrivilege: await createSupertestWithCustomRole( - `${artifact.listId}_allWithGlobal`, - { - [SECURITY_FEATURE_ID]: [ - 'minimal_read', - artifact.all, - 'global_artifact_management_all', - ], - } - ), - }; - } - }); - ENDPOINT_ARTIFACTS.forEach((artifact) => { describe(`Importing ${artifact.name}`, () => { let fetchArtifacts: ReturnType; - before(() => { + before(async () => { fetchArtifacts = getFetchArtifacts(endpointOpsAnalystSupertest, log, artifact.listId); + + await optInForPerPolicyEndpointExceptions(kbnServer); }); beforeEach(async () => { @@ -191,6 +195,10 @@ export default function artifactImportAPIIntegrationTests({ getService }: FtrPro await endpointArtifactTestResources.deleteList(artifact.listId); }); + after(async () => { + await disablePerPolicyEndpointExceptions(kbnServer); + }); + describe('when checking privileges', () => { it(`should error when importing without artifact privileges`, async () => { await supertest[artifact.listId].none @@ -1404,92 +1412,118 @@ export default function artifactImportAPIIntegrationTests({ getService }: FtrPro ); }); }); + }); + } - describe('when importing endpoint exceptions', () => { - let fetchArtifacts: ReturnType; + // we should keep the same behavior for Endpoint exceptions import until the user has opted-in to per-policy usage + describe('when importing endpoint exceptions (without FF) and (with FF without per-policy opt-in)', () => { + let fetchArtifacts: ReturnType; - before(() => { - fetchArtifacts = getFetchArtifacts( - endpointOpsAnalystSupertest, - log, - ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id - ); - }); + before(async () => { + fetchArtifacts = getFetchArtifacts( + endpointOpsAnalystSupertest, + log, + ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id + ); - beforeEach(async () => { - await endpointArtifactTestResources.deleteList( - ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id - ); + await disablePerPolicyEndpointExceptions(kbnServer); + }); - await deleteExceptionList( - endpointOpsAnalystSupertest, - ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id, - 'single' - ); - }); + beforeEach(async () => { + await endpointArtifactTestResources.deleteList( + ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id + ); - afterEach(async () => { - await endpointArtifactTestResources.deleteList( - ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id - ); + await deleteExceptionList( + endpointOpsAnalystSupertest, + ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id, + 'single' + ); + }); - await deleteExceptionList( - endpointOpsAnalystSupertest, - ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id, - 'single' - ); - }); + afterEach(async () => { + await endpointArtifactTestResources.deleteList( + ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id + ); - it('should add global artifact tag if owner space ID is missing', async () => { - await endpointOpsAnalystSupertest - .post(`${EXCEPTION_LIST_URL}/_import`) - .set('kbn-xsrf', 'true') - .on('error', createSupertestErrorLogger(log)) - .attach( - 'file', - buildImportBuffer(ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id, [ - { tags: [] }, - { tags: [] }, - ]), - 'import_exceptions.ndjson' - ) - .expect(200); - - const items = await fetchArtifacts(CURRENT_SPACE_ID); - - expect(items.length).toEqual(2); - for (const endpointException of items) { - expect(endpointException.tags).toContain(GLOBAL_ARTIFACT_TAG); - } - }); + await deleteExceptionList( + endpointOpsAnalystSupertest, + ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id, + 'single' + ); + }); - it('should not add global artifact tag if namespace is "single"', async () => { - await endpointArtifactTestResources.createList( - ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id - ); + it('should add owner space id tag and global artifact tag if owner space ID is missing', async () => { + await endpointOpsAnalystSupertest + .post(`${EXCEPTION_LIST_URL}/_import`) + .set('kbn-xsrf', 'true') + .on('error', createSupertestErrorLogger(log)) + .attach( + 'file', + buildImportBuffer(ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id, [ + { tags: [] }, + { tags: [] }, + ]), + 'import_exceptions.ndjson' + ) + .expect(200); + + const items = await fetchArtifacts(CURRENT_SPACE_ID); + + expect(items.length).toEqual(2); + for (const endpointException of items) { + expect(endpointException.tags).toEqual([GLOBAL_ARTIFACT_TAG, CURRENT_SPACE_OWNER_TAG]); + } + }); - await endpointOpsAnalystSupertest - .post(`${EXCEPTION_LIST_URL}/_import`) - .set('kbn-xsrf', 'true') - .on('error', createSupertestErrorLogger(log)) - .attach( - 'file', - buildImportBuffer( - ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id, - [{ namespace_type: 'single' }], - 'single' - ), - 'import_exceptions.ndjson' - ) - .expect(200); - - const items = await fetchArtifacts(CURRENT_SPACE_ID, 'single'); - expect(items.length).toEqual(1); - expect(items[0].tags).not.toContain(GLOBAL_ARTIFACT_TAG); - }); - }); + it('should add global artifact tag if it is missing', async () => { + await endpointOpsAnalystSupertest + .post(`${EXCEPTION_LIST_URL}/_import`) + .set('kbn-xsrf', 'true') + .on('error', createSupertestErrorLogger(log)) + .attach( + 'file', + buildImportBuffer(ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id, [ + { tags: [CURRENT_SPACE_OWNER_TAG] }, + { tags: [CURRENT_SPACE_OWNER_TAG] }, + ]), + 'import_exceptions.ndjson' + ) + .expect(200); + + const items = await fetchArtifacts(CURRENT_SPACE_ID); + + expect(items.length).toEqual(2); + for (const endpointException of items) { + expect(endpointException.tags).toEqual([CURRENT_SPACE_OWNER_TAG, GLOBAL_ARTIFACT_TAG]); + } }); - } + + it('should not add global artifact tag if namespace is "single"', async () => { + await endpointArtifactTestResources.createList( + ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id + ); + + await endpointOpsAnalystSupertest + .post(`${EXCEPTION_LIST_URL}/_import`) + .set('kbn-xsrf', 'true') + .on('error', createSupertestErrorLogger(log)) + .attach( + 'file', + buildImportBuffer( + ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id, + [{ namespace_type: 'single' }], + 'single' + ), + 'import_exceptions.ndjson' + ) + .expect(200); + + const items = await fetchArtifacts(CURRENT_SPACE_ID, 'single'); + expect(items.length).toEqual(1); + expect(items[0].tags).not.toContain(GLOBAL_ARTIFACT_TAG); + }); + }); }); } diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/configs/endpoint_exceptions_moved_ff.ess.config.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/configs/endpoint_exceptions_moved_ff.ess.config.ts index bf5cd2a5f54d6..cbc9539c88803 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/configs/endpoint_exceptions_moved_ff.ess.config.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/configs/endpoint_exceptions_moved_ff.ess.config.ts @@ -43,6 +43,14 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { `--xpack.securitySolution.enableExperimental=${JSON.stringify( securitySolutionEnableExperimental )}`, + + // Uncomment to enable debug logger to see full eval traces in kibana logs + // `--logging.loggers=${JSON.stringify([ + // { + // name: 'plugins.securitySolution', + // level: 'debug', + // }, + // ])}`, ], }, }; diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/endpoint_exceptions.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/endpoint_exceptions.ts index dd390d3e9c39c..308602aeacfb1 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/endpoint_exceptions.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/endpoint_exceptions.ts @@ -22,6 +22,10 @@ import { import type { ArtifactTestData } from '@kbn/test-suites-xpack-security-endpoint/services/endpoint_artifacts'; import type { PolicyTestResourceInfo } from '@kbn/test-suites-xpack-security-endpoint/services/endpoint_policy'; import { getHunter } from '@kbn/security-solution-plugin/scripts/endpoint/common/roles_users'; +import { + disablePerPolicyEndpointExceptions, + optInForPerPolicyEndpointExceptions, +} from '@kbn/security-solution-plugin/scripts/endpoint/common/endpoint_artifact_services'; import type { CustomRole } from '../../../../config/services/types'; import { ROLE } from '../../../../config/services/security_solution_edr_workflows_roles_users'; import type { FtrProviderContext } from '../../../../ftr_provider_context_edr_workflows'; @@ -31,6 +35,7 @@ export default function ({ getService }: FtrProviderContext) { const endpointArtifactTestResources = getService('endpointArtifactTestResources'); const utils = getService('securitySolutionUtils'); const config = getService('config'); + const kibanaServer = getService('kibanaServer'); const IS_ENDPOINT_EXCEPTION_MOVE_FF_ENABLED = ( config.get('kbnTestServer.serverArgs', []) as string[] @@ -54,6 +59,12 @@ export default function ({ getService }: FtrProviderContext) { fleetEndpointPolicy = await endpointPolicyTestResources.createPolicy(); }); + beforeEach(async () => { + if (IS_ENDPOINT_EXCEPTION_MOVE_FF_ENABLED) { + await disablePerPolicyEndpointExceptions(kibanaServer); + } + }); + after(async () => { if (fleetEndpointPolicy) { await fleetEndpointPolicy.cleanup(); @@ -211,7 +222,9 @@ export default function ({ getService }: FtrProviderContext) { }); if (IS_ENDPOINT_EXCEPTION_MOVE_FF_ENABLED) { - it(`should accept item on [${endpointExceptionApiCall.method}] if no assignment tag is present`, async () => { + it(`should accept item on [${endpointExceptionApiCall.method}] if no assignment tag is present after user has opted in for per policy`, async () => { + await optInForPerPolicyEndpointExceptions(kibanaServer); + const requestBody = endpointExceptionApiCall.getBody(); requestBody.tags = []; @@ -226,6 +239,22 @@ export default function ({ getService }: FtrProviderContext) { const deleteUrl = `${EXCEPTION_LIST_ITEM_URL}?item_id=${requestBody.item_id}&namespace_type=${requestBody.namespace_type}`; await endpointPolicyManagerSupertest.delete(deleteUrl).set('kbn-xsrf', 'true'); }); + + it(`should add global artifact tag on [${endpointExceptionApiCall.method}] if no assignment tag is present if user has not opted in for per policy`, async () => { + const requestBody = endpointExceptionApiCall.getBody(); + requestBody.tags = []; + + await endpointPolicyManagerSupertest[endpointExceptionApiCall.method]( + endpointExceptionApiCall.path + ) + .set('kbn-xsrf', 'true') + .send(requestBody) + .expect(200) + .expect(({ body }) => expect(body.tags).to.contain(GLOBAL_ARTIFACT_TAG)); + + const deleteUrl = `${EXCEPTION_LIST_ITEM_URL}?item_id=${requestBody.item_id}&namespace_type=${requestBody.namespace_type}`; + await endpointPolicyManagerSupertest.delete(deleteUrl).set('kbn-xsrf', 'true'); + }); } else { it(`should add global artifact tag on [${endpointExceptionApiCall.method}] if no assignment tag is present`, async () => { const requestBody = endpointExceptionApiCall.getBody(); @@ -246,6 +275,8 @@ export default function ({ getService }: FtrProviderContext) { if (IS_ENDPOINT_EXCEPTION_MOVE_FF_ENABLED) { it(`should error on [${endpointExceptionApiCall.method}] if policy id is invalid`, async () => { + await optInForPerPolicyEndpointExceptions(kibanaServer); + const body = endpointExceptionApiCall.getBody(); body.tags = [buildPerPolicyTag('123')]; @@ -302,6 +333,8 @@ export default function ({ getService }: FtrProviderContext) { for (const endpointExceptionApiCall of endpointExceptionCalls) { if (IS_ENDPOINT_EXCEPTION_MOVE_FF_ENABLED) { it(`should error on [${endpointExceptionApiCall.method}] - [${endpointExceptionApiCall.info}] when global artifact is the target`, async () => { + await optInForPerPolicyEndpointExceptions(kibanaServer); + const requestBody = endpointExceptionApiCall.getBody(); // keep space tag, but replace any per-policy tags with a global tag requestBody.tags = [ @@ -324,6 +357,8 @@ export default function ({ getService }: FtrProviderContext) { }); it(`should work on [${endpointExceptionApiCall.method}] - [${endpointExceptionApiCall.info}] when per-policy artifact is the target`, async () => { + await optInForPerPolicyEndpointExceptions(kibanaServer); + const requestBody = endpointExceptionApiCall.getBody(); // remove existing tag diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/endpoint_exceptions_per_policy_opt_in.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/endpoint_exceptions_per_policy_opt_in.ts new file mode 100644 index 0000000000000..2835342ce6d60 --- /dev/null +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/endpoint_exceptions_per_policy_opt_in.ts @@ -0,0 +1,243 @@ +/* + * 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 expect from '@kbn/expect'; +import type TestAgent from 'supertest/lib/agent'; +import { SECURITY_FEATURE_ID } from '@kbn/security-solution-plugin/common'; +import { ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE } from '@kbn/security-solution-plugin/common/endpoint/constants'; +import { + deleteEndpointExceptionsPerPolicyOptInSO, + disablePerPolicyEndpointExceptions, + findEndpointExceptionsPerPolicyOptInSO, +} from '@kbn/security-solution-plugin/scripts/endpoint/common/endpoint_artifact_services'; +import { ENDPOINT_ARTIFACT_LISTS } from '@kbn/securitysolution-list-constants'; +import { ENDPOINT_EXCEPTIONS_LIST_DEFINITION } from '@kbn/security-solution-plugin/public/management/pages/endpoint_exceptions/constants'; +import type { CustomRole } from '../../../../config/services/types'; +import type { FtrProviderContext } from '../../../../ftr_provider_context_edr_workflows'; + +export default function endpointExceptionsPerPolicyOptInTests({ getService }: FtrProviderContext) { + const utils = getService('securitySolutionUtils'); + const config = getService('config'); + const kibanaServer = getService('kibanaServer'); + const endpointArtifactTestResources = getService('endpointArtifactTestResources'); + + const isServerless = config.get('serverless'); + const username = isServerless ? 'elastic_admin' : 'elastic'; + const superuserRole = isServerless ? 'admin' : 'elastic'; + + const IS_ENDPOINT_EXCEPTION_MOVE_FF_ENABLED = ( + config.get('kbnTestServer.serverArgs', []) as string[] + ) + .find((s) => s.startsWith('--xpack.securitySolution.enableExperimental')) + ?.includes('endpointExceptionsMovedUnderManagement'); + + const HEADERS = { + 'x-elastic-internal-origin': 'kibana', + 'Elastic-Api-Version': '1', + 'kbn-xsrf': 'true', + }; + + describe('@ess @serverless @skipInServerlessMKI Endpoint Exceptions Per Policy Opt-In API', function () { + beforeEach(async () => { + await disablePerPolicyEndpointExceptions(kibanaServer); + }); + + if (IS_ENDPOINT_EXCEPTION_MOVE_FF_ENABLED) { + describe('When Endpoint Exceptions moved FF is enabled', () => { + let superuser: TestAgent; + let endpointExceptionsAllUser: TestAgent; + + before(async () => { + superuser = await utils.createSuperTest(superuserRole); + + endpointExceptionsAllUser = await utils.createSuperTestWithCustomRole( + buildRole('endpointExceptionsAllUser', [ + 'all', + 'endpoint_exceptions_all', + 'global_artifact_management_all', + ]) + ); + }); + + describe('POST /internal/api/endpoint/endpoint_exceptions_per_policy_opt_in', () => { + describe('RBAC', () => { + it('should respond 403 even with Endpoint Exceptions ALL privilege if user is not superuser', async () => { + await endpointExceptionsAllUser + .post(ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE) + .set(HEADERS) + .expect(403); + }); + + it('should respond 200 with superuser', async () => { + await superuser + .post(ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE) + .set(HEADERS) + .expect(200); + }); + }); + + describe('functionality', () => { + it('should store the opt-in status, reason, user, and timestamp in reference data', async () => { + const initialOptInStatusSO = await findEndpointExceptionsPerPolicyOptInSO( + kibanaServer + ); + expect(initialOptInStatusSO?.attributes.metadata.status).to.be(false); + + await superuser + .post(ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE) + .set(HEADERS) + .expect(200); + + const optInStatusSO = await findEndpointExceptionsPerPolicyOptInSO(kibanaServer); + expect(optInStatusSO?.attributes.metadata.status).to.be(true); + expect(optInStatusSO?.attributes.metadata.reason).to.be('userOptedIn'); + expect(optInStatusSO?.attributes.metadata.user).to.be(username); + expect(optInStatusSO?.attributes.metadata.timestamp).to.be.a('string'); + + const nowVsOptInTimestamp = + new Date().getTime() - + new Date(optInStatusSO?.attributes.metadata.timestamp ?? '').getTime(); + expect(Math.abs(nowVsOptInTimestamp)).to.be.lessThan(10_000); + }); + + it('should have an idempotent behavior', async () => { + const initialOptInStatusSO = await findEndpointExceptionsPerPolicyOptInSO( + kibanaServer + ); + expect(initialOptInStatusSO?.attributes.metadata.status).to.be(false); + + await superuser + .post(ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE) + .set(HEADERS) + .expect(200); + await superuser + .post(ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE) + .set(HEADERS) + .expect(200); + + const optInStatusSO = await findEndpointExceptionsPerPolicyOptInSO(kibanaServer); + expect(optInStatusSO?.attributes.metadata.status).to.be(true); + }); + }); + }); + + describe('GET /internal/api/endpoint/endpoint_exceptions_per_policy_opt_in', () => { + describe('RBAC', () => { + it('should respond 403 without Endpoint Exceptions READ privilege', async () => { + const noEndpointExceptionsAccessUser = await utils.createSuperTestWithCustomRole( + buildRole('endpointExceptionsAllUser', ['all', 'global_artifact_management_all']) + ); + + await noEndpointExceptionsAccessUser + .get(ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE) + .set(HEADERS) + .expect(403); + }); + + it('should respond 200 with Endpoint Exceptions READ privilege', async () => { + const endpointExceptionsReadUser = await utils.createSuperTestWithCustomRole( + buildRole('endpointExceptionsReadUser', ['all', 'endpoint_exceptions_read']) + ); + await endpointExceptionsReadUser + .get(ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE) + .set(HEADERS) + .expect(200); + }); + }); + + describe('functionality', () => { + it('should return `false` opt-in status for upgraded deployments', async () => { + // Simulate upgraded deployment by ensuring the SO does not exist and creating + // the Endpoint exceptions list as this is the base for deciding the default opt-in status + await endpointArtifactTestResources.ensureListExists( + ENDPOINT_EXCEPTIONS_LIST_DEFINITION, + { supertest: superuser } + ); + + const response = await superuser + .get(ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE) + .set(HEADERS) + .expect(200); + + expect(response.body).to.eql({ status: false }); + }); + + it('should return `true` opt-in status when it is a new deployment', async () => { + // Simulate new deployment by ensuring the SO does not exist and deleting + await deleteEndpointExceptionsPerPolicyOptInSO(kibanaServer); + // the Endpoint exceptions list as this is the base for deciding the default opt-in status + await endpointArtifactTestResources.deleteList( + ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id, + superuser + ); + + const response = await superuser + .get(ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE) + .set(HEADERS) + .expect(200); + + expect(response.body).to.eql({ status: true, reason: 'newDeployment' }); + }); + + it('should return `true` opt-in status when it has been set', async () => { + await superuser + .post(ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE) + .set(HEADERS) + .expect(200); + + const response = await superuser + .get(ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE) + .set(HEADERS) + .expect(200); + + expect(response.body).to.eql({ status: true, reason: 'userOptedIn' }); + }); + }); + }); + }); + } else { + describe('When Endpoint Exceptions moved FF is disabled', () => { + describe('POST /internal/api/endpoint/endpoint_exceptions_per_policy_opt_in', () => { + it('should respond 404', async () => { + const superuser = await utils.createSuperTest(superuserRole); + + await superuser + .post(ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE) + .set(HEADERS) + .expect(404); + }); + }); + + describe('GET /internal/api/endpoint/endpoint_exceptions_per_policy_opt_in', () => { + it('should respond 404', async () => { + const superuser = await utils.createSuperTest(superuserRole); + + await superuser + .get(ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE) + .set(HEADERS) + .expect(404); + }); + }); + }); + } + }); +} + +const buildRole = (name: string, siemPrivileges: string[]): CustomRole => ({ + name, + privileges: { + kibana: [ + { + base: [], + feature: { + [SECURITY_FEATURE_ID]: siemPrivileges, + }, + spaces: ['*'], + }, + ], + elasticsearch: { cluster: [], indices: [] }, + }, +}); diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/endpoint_list_api_rbac.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/endpoint_list_api_rbac.ts index bbbd4a922c737..680904f9266a3 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/endpoint_list_api_rbac.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/endpoint_list_api_rbac.ts @@ -19,6 +19,10 @@ import { import type { ArtifactTestData } from '@kbn/test-suites-xpack-security-endpoint/services/endpoint_artifacts'; import type { PolicyTestResourceInfo } from '@kbn/test-suites-xpack-security-endpoint/services/endpoint_policy'; import { getHunter } from '@kbn/security-solution-plugin/scripts/endpoint/common/roles_users'; +import { + disablePerPolicyEndpointExceptions, + optInForPerPolicyEndpointExceptions, +} from '@kbn/security-solution-plugin/scripts/endpoint/common/endpoint_artifact_services'; import type { CustomRole } from '../../../../config/services/types'; import { ROLE } from '../../../../config/services/security_solution_edr_workflows_roles_users'; import type { FtrProviderContext } from '../../../../ftr_provider_context_edr_workflows'; @@ -28,6 +32,7 @@ export default function ({ getService }: FtrProviderContext) { const endpointArtifactTestResources = getService('endpointArtifactTestResources'); const utils = getService('securitySolutionUtils'); const config = getService('config'); + const kibanaServer = getService('kibanaServer'); const IS_ENDPOINT_EXCEPTION_MOVE_FF_ENABLED = ( config.get('kbnTestServer.serverArgs', []) as string[] @@ -49,6 +54,12 @@ export default function ({ getService }: FtrProviderContext) { fleetEndpointPolicy = await endpointPolicyTestResources.createPolicy(); }); + beforeEach(async () => { + if (IS_ENDPOINT_EXCEPTION_MOVE_FF_ENABLED) { + await optInForPerPolicyEndpointExceptions(kibanaServer); + } + }); + after(async () => { if (fleetEndpointPolicy) { await fleetEndpointPolicy.cleanup(); @@ -184,7 +195,9 @@ export default function ({ getService }: FtrProviderContext) { }); if (IS_ENDPOINT_EXCEPTION_MOVE_FF_ENABLED) { - it(`should accept item on [${endpointListApiCall.method}] if no assignment tag is present`, async () => { + it(`should accept item on [${endpointListApiCall.method}] if no assignment tag is present after user has opted in for per policy`, async () => { + await optInForPerPolicyEndpointExceptions(kibanaServer); + const requestBody = endpointListApiCall.getBody(); requestBody.tags = []; @@ -199,6 +212,24 @@ export default function ({ getService }: FtrProviderContext) { const deleteUrl = `${ENDPOINT_LIST_ITEM_URL}?item_id=${requestBody.item_id}`; await endpointPolicyManagerSupertest.delete(deleteUrl).set('kbn-xsrf', 'true'); }); + + it(`should add global artifact tag on [${endpointListApiCall.method}] if no assignment tag is present if user has not opted in for per policy`, async () => { + await disablePerPolicyEndpointExceptions(kibanaServer); + + const requestBody = endpointListApiCall.getBody(); + requestBody.tags = []; + + await endpointPolicyManagerSupertest[endpointListApiCall.method]( + endpointListApiCall.path + ) + .set('kbn-xsrf', 'true') + .send(requestBody) + .expect(200) + .expect(({ body }) => expect(body.tags).to.contain(GLOBAL_ARTIFACT_TAG)); + + const deleteUrl = `${ENDPOINT_LIST_ITEM_URL}?item_id=${requestBody.item_id}`; + await endpointPolicyManagerSupertest.delete(deleteUrl).set('kbn-xsrf', 'true'); + }); } else { it(`should add global artifact tag on [${endpointListApiCall.method}] if no assignment tag is present`, async () => { const requestBody = endpointListApiCall.getBody(); diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/index.endpoint_exceptions_moved_ff.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/index.endpoint_exceptions_moved_ff.ts index 09c6168608fb1..6555ed1b1a125 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/index.endpoint_exceptions_moved_ff.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/index.endpoint_exceptions_moved_ff.ts @@ -60,5 +60,6 @@ export default function endpointAPIIntegrationTests(providerContext: FtrProvider loadTestFile(require.resolve('./endpoint_exceptions')); loadTestFile(require.resolve('./endpoint_list_api_rbac')); loadTestFile(require.resolve('./artifact_import')); + loadTestFile(require.resolve('./endpoint_exceptions_per_policy_opt_in')); }); } diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/index.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/index.ts index c2f343198815e..1f6adf00aeb2c 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/index.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/edr_workflows/artifacts/trial_license_complete_tier/index.ts @@ -60,5 +60,6 @@ export default function endpointAPIIntegrationTests(providerContext: FtrProvider loadTestFile(require.resolve('./endpoint_exceptions')); loadTestFile(require.resolve('./endpoint_list_api_rbac')); loadTestFile(require.resolve('./artifact_import')); + loadTestFile(require.resolve('./endpoint_exceptions_per_policy_opt_in')); }); }