From d91c36f998c7ed4143573d0babdebe30f5ac2d4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Sun, 22 Mar 2026 17:05:24 +0100 Subject: [PATCH 01/46] [data] add endpoint opt-in status to reference dataset --- .../server/endpoint/lib/reference_data/constants.ts | 13 +++++++++++++ .../server/endpoint/lib/reference_data/types.ts | 12 +++++++----- 2 files changed, 20 insertions(+), 5 deletions(-) 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..41ba274ea5085 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 @@ -7,6 +7,7 @@ import type { MigrationMetadata, + OptInStatusMetadata, OrphanResponseActionsMetadata, ReferenceDataItemKey, ReferenceDataSavedObject, @@ -24,6 +25,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; /** @@ -66,4 +71,12 @@ export const REF_DATA_KEY_INITIAL_VALUE: Readonly< type: 'RESPONSE-ACTIONS', metadata: { spaceId: '' }, }), + + [REF_DATA_KEYS.endpointExceptionsPerPolicyOptInStatus]: + (): ReferenceDataSavedObject => ({ + 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/types.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/lib/reference_data/types.ts index 50f4f1b5ac16d..7e7128534ee43 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,7 @@ export interface MigrationMetadata { export interface OrphanResponseActionsMetadata { spaceId: string; } + +export interface OptInStatusMetadata { + status: boolean; +} From 65b207f295c48f06d32657a94030c212c384d106 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Sun, 22 Mar 2026 17:09:28 +0100 Subject: [PATCH 02/46] [api] add POST API to allow opting in --- .../common/endpoint/constants.ts | 3 + .../endpoint_exceptions_per_policy_opt_in.ts | 68 +++++++++++++++++++ .../index.ts | 19 ++++++ .../security_solution/server/plugin.ts | 2 + 4 files changed, 92 insertions(+) create mode 100644 x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/endpoint_exceptions_per_policy_opt_in/endpoint_exceptions_per_policy_opt_in.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/endpoint_exceptions_per_policy_opt_in/index.ts 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/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..c3a6b2733e946 --- /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,68 @@ +/* + * 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 { + 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'; + +export const getOptInToPerPolicyEndpointExceptionsHandler = ( + 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 + ); + + currentOptInStatus.metadata.status = true; + + 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 registerEndpointExceptionsPerPolicyOptInRoute = ( + router: SecuritySolutionPluginRouter, + endpointContext: EndpointAppContext +) => { + router.versioned + .post({ + access: 'internal', + path: ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE, + security: { + authz: { requiredPrivileges: [ReservedPrivilegesSet.superuser] }, + }, + }) + .addVersion( + { + version: '1', + validate: {}, + }, + getOptInToPerPolicyEndpointExceptionsHandler(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/plugin.ts b/x-pack/solutions/security/plugins/security_solution/server/plugin.ts index 4a7810bc6d3a7..f3d4539153884 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/plugin.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/plugin.ts @@ -161,6 +161,7 @@ import { AIValueReportLocatorDefinition } from '../common/locators/ai_value_repo import type { TrialCompanionRoutesDeps } from './lib/trial_companion/types'; import { setupAlertsCapabilitiesSwitcher } from './lib/capabilities/alerts_capabilities_switcher'; import { securityAlertsProfileInitializer } from './lib/anonymization'; +import { registerEndpointExceptionsRoutes } from './endpoint/routes/endpoint_exceptions_per_policy_opt_in'; export type { SetupPlugins, StartPlugins, PluginSetup, PluginStart } from './plugin_contract'; @@ -564,6 +565,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) { From 130a7a48ee6e0b3035e788b9634971341231ae82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Sun, 22 Mar 2026 17:10:36 +0100 Subject: [PATCH 03/46] [api] add test coverage for POST api --- .../endpoint_exceptions_per_policy_opt_in.ts | 164 ++++++++++++++++++ .../index.endpoint_exceptions_moved_ff.ts | 1 + .../trial_license_complete_tier/index.ts | 1 + 3 files changed, 166 insertions(+) create mode 100644 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 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..e0eb3080d7931 --- /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,164 @@ +/* + * 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 type { + OptInStatusMetadata, + ReferenceDataSavedObject, +} from '@kbn/security-solution-plugin/server/endpoint/lib/reference_data'; +import { + REF_DATA_KEYS, + REFERENCE_DATA_SAVED_OBJECT_TYPE, +} from '@kbn/security-solution-plugin/server/endpoint/lib/reference_data'; +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 isServerless = config.get('serverless'); + 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 OPT_IN_STATUS_DESCRIPTOR = { + type: REFERENCE_DATA_SAVED_OBJECT_TYPE, + id: REF_DATA_KEYS.endpointExceptionsPerPolicyOptInStatus, + }; + + const HEADERS = { + 'x-elastic-internal-origin': 'kibana', + 'Elastic-Api-Version': '1', + 'kbn-xsrf': 'true', + }; + + const findOptInStatusSO = async (): Promise< + ReferenceDataSavedObject | undefined + > => { + const foundReferenceObjects = await kibanaServer.savedObjects.find({ + type: REFERENCE_DATA_SAVED_OBJECT_TYPE, + }); + + return foundReferenceObjects.saved_objects.find( + (obj) => obj.id === REF_DATA_KEYS.endpointExceptionsPerPolicyOptInStatus + )?.attributes as ReferenceDataSavedObject | undefined; + }; + + describe('@ess @serverless @skipInServerlessMKI Endpoint Exceptions Per Policy Opt-In API', function () { + beforeEach(async () => { + const foundReferenceDataSavedObjects = await kibanaServer.savedObjects.find({ + type: REFERENCE_DATA_SAVED_OBJECT_TYPE, + }); + + if ( + foundReferenceDataSavedObjects.saved_objects.find( + (obj) => obj.id === REF_DATA_KEYS.endpointExceptionsPerPolicyOptInStatus + ) + ) { + await kibanaServer.savedObjects.delete(OPT_IN_STATUS_DESCRIPTOR); + } + }); + + 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({ + name: 'endpointExceptionsAllUser', + privileges: { + kibana: [ + { + base: [], + feature: { + [SECURITY_FEATURE_ID]: [ + 'all', + 'endpoint_exceptions_all', + 'global_artifact_management_all', + ], + }, + spaces: ['*'], + }, + ], + elasticsearch: { cluster: [], indices: [] }, + }, + }); + }); + + 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 in reference data', async () => { + const initialOptInStatusSO = await findOptInStatusSO(); + expect(initialOptInStatusSO).to.be(undefined); + + await superuser + .post(ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE) + .set(HEADERS) + .expect(200); + + const optInStatusSO = await findOptInStatusSO(); + expect(optInStatusSO?.metadata.status).to.be(true); + }); + + it('should have an idempotent behavior', async () => { + const initialOptInStatusSO = await findOptInStatusSO(); + expect(initialOptInStatusSO).to.be(undefined); + + 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 findOptInStatusSO(); + expect(optInStatusSO?.metadata.status).to.be(true); + }); + }); + }); + } else { + describe('When Endpoint Exceptions moved FF is disabled', () => { + it('should respond 404', async () => { + const username = await utils.getUsername(superuserRole); + const superuser = await utils.createSuperTest(username); + + await superuser + .post(ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE) + .set(HEADERS) + .expect(404); + }); + }); + } + }); +} 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')); }); } From 3cc05d666e5c1e12524656e6ac20bf58c4623aad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Mon, 23 Mar 2026 13:35:19 +0100 Subject: [PATCH 04/46] [api] add GET API to allow fetching opt-in status --- .../endpoint_exceptions_per_policy_opt_in.ts | 10 + .../endpoint_exceptions_per_policy_opt_in.ts | 54 ++++- .../endpoint_exceptions_per_policy_opt_in.ts | 193 ++++++++++++------ 3 files changed, 192 insertions(+), 65 deletions(-) create mode 100644 x-pack/solutions/security/plugins/security_solution/common/endpoint/types/endpoint_exceptions_per_policy_opt_in.ts diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/endpoint_exceptions_per_policy_opt_in.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/endpoint_exceptions_per_policy_opt_in.ts new file mode 100644 index 0000000000000..1a077e9d0d783 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/endpoint_exceptions_per_policy_opt_in.ts @@ -0,0 +1,10 @@ +/* + * 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. + */ + +export interface GetEndpointExceptionsPerPolicyOptInResponse { + status: boolean; +} 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 index c3a6b2733e946..835ff7886f5ca 100644 --- 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 @@ -6,6 +6,7 @@ */ import { ReservedPrivilegesSet, type RequestHandler } from '@kbn/core/server'; +import type { GetEndpointExceptionsPerPolicyOptInResponse } from '../../../../common/endpoint/types/endpoint_exceptions_per_policy_opt_in'; import type { SecuritySolutionPluginRouter, SecuritySolutionRequestHandlerContext, @@ -16,10 +17,11 @@ import type { EndpointAppContextService } from '../../endpoint_app_context_servi 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 getOptInToPerPolicyEndpointExceptionsHandler = ( +export const getOptInToPerPolicyEndpointExceptionsPOSTHandler = ( endpointAppServices: EndpointAppContextService -): RequestHandler => { +): RequestHandler => { const logger = endpointAppServices.createLogger('endpointExceptionsPerPolicyOptInHandler'); return async (context, req, res) => { @@ -46,10 +48,36 @@ export const getOptInToPerPolicyEndpointExceptionsHandler = ( }; }; +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, + }; + + 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', @@ -63,6 +91,26 @@ export const registerEndpointExceptionsPerPolicyOptInRoute = ( version: '1', validate: {}, }, - getOptInToPerPolicyEndpointExceptionsHandler(endpointContext.service) + 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/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 index e0eb3080d7931..2275d8d72cd77 100644 --- 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 @@ -16,6 +16,7 @@ import { REF_DATA_KEYS, REFERENCE_DATA_SAVED_OBJECT_TYPE, } from '@kbn/security-solution-plugin/server/endpoint/lib/reference_data'; +import type { CustomRole } from '../../../../config/services/types'; import type { FtrProviderContext } from '../../../../ftr_provider_context_edr_workflows'; export default function endpointExceptionsPerPolicyOptInTests({ getService }: FtrProviderContext) { @@ -78,87 +79,155 @@ export default function endpointExceptionsPerPolicyOptInTests({ getService }: Ft before(async () => { superuser = await utils.createSuperTest(superuserRole); - endpointExceptionsAllUser = await utils.createSuperTestWithCustomRole({ - name: 'endpointExceptionsAllUser', - privileges: { - kibana: [ - { - base: [], - feature: { - [SECURITY_FEATURE_ID]: [ - 'all', - 'endpoint_exceptions_all', - 'global_artifact_management_all', - ], - }, - spaces: ['*'], - }, - ], - elasticsearch: { cluster: [], indices: [] }, - }, - }); + endpointExceptionsAllUser = await utils.createSuperTestWithCustomRole( + buildRole('endpointExceptionsAllUser', [ + 'all', + 'endpoint_exceptions_all', + 'global_artifact_management_all', + ]) + ); }); - 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); + 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); + }); }); - 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 in reference data', async () => { + const initialOptInStatusSO = await findOptInStatusSO(); + expect(initialOptInStatusSO).to.be(undefined); + + await superuser + .post(ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE) + .set(HEADERS) + .expect(200); + + const optInStatusSO = await findOptInStatusSO(); + expect(optInStatusSO?.metadata.status).to.be(true); + }); + + it('should have an idempotent behavior', async () => { + const initialOptInStatusSO = await findOptInStatusSO(); + expect(initialOptInStatusSO).to.be(undefined); + + 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 findOptInStatusSO(); + expect(optInStatusSO?.metadata.status).to.be(true); + }); }); }); - describe('functionality', () => { - it('should store the opt-in status in reference data', async () => { - const initialOptInStatusSO = await findOptInStatusSO(); - expect(initialOptInStatusSO).to.be(undefined); + 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 when it has not been set', async () => { + const response = await superuser + .get(ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE) + .set(HEADERS) + .expect(200); + + expect(response.body.status).to.be(false); + }); + + 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.status).to.be(true); + }); + }); + }); + }); + } 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(200); - - const optInStatusSO = await findOptInStatusSO(); - expect(optInStatusSO?.metadata.status).to.be(true); + .expect(404); }); + }); - it('should have an idempotent behavior', async () => { - const initialOptInStatusSO = await findOptInStatusSO(); - expect(initialOptInStatusSO).to.be(undefined); + describe('GET /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(200); - await superuser - .post(ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE) + .get(ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE) .set(HEADERS) - .expect(200); - - const optInStatusSO = await findOptInStatusSO(); - expect(optInStatusSO?.metadata.status).to.be(true); + .expect(404); }); }); }); - } else { - describe('When Endpoint Exceptions moved FF is disabled', () => { - it('should respond 404', async () => { - const username = await utils.getUsername(superuserRole); - const superuser = await utils.createSuperTest(username); - - await superuser - .post(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: [] }, + }, +}); From a99ddc02cd788a294d01675564c051ebed581aad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Mon, 23 Mar 2026 15:01:49 +0100 Subject: [PATCH 05/46] [docs] add api documentation --- .../supertest/endpoint_management.gen.ts | 24 +++++++++++++ ...dpoint_exceptions_per_policy_opt_in.gen.ts | 24 +++++++++++++ ...t_exceptions_per_policy_opt_in.schema.yaml | 35 +++++++++++++++++++ .../common/api/quickstart_client.gen.ts | 27 ++++++++++++++ 4 files changed, 110 insertions(+) create mode 100644 x-pack/solutions/security/plugins/security_solution/common/api/endpoint/endpoint_exceptions_per_policy_opt_in/endpoint_exceptions_per_policy_opt_in.gen.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/common/api/endpoint/endpoint_exceptions_per_policy_opt_in/endpoint_exceptions_per_policy_opt_in.schema.yaml 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..e2c25f41236b5 --- /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,24 @@ +/* + * 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(), +}); 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..d864150fd3f23 --- /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,35 @@ +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: [ess, serverless] + x-internal: true + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: + - status + properties: + status: + type: boolean + + post: + summary: Opt-in to endpoint exceptions per policy + operationId: PerformEndpointExceptionsPerPolicyOptIn + x-codegen-enabled: true + 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 465e1b97660f9..fe11e2417b99d 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, @@ -1868,6 +1869,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 @@ -2628,6 +2641,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. From 5e2e906896743cbebf6dece7284be797191b70d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Mon, 23 Mar 2026 15:02:32 +0100 Subject: [PATCH 06/46] [api] remove manually created response type --- .../types/endpoint_exceptions_per_policy_opt_in.ts | 10 ---------- .../endpoint_exceptions_per_policy_opt_in.ts | 2 +- 2 files changed, 1 insertion(+), 11 deletions(-) delete mode 100644 x-pack/solutions/security/plugins/security_solution/common/endpoint/types/endpoint_exceptions_per_policy_opt_in.ts diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/endpoint_exceptions_per_policy_opt_in.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/endpoint_exceptions_per_policy_opt_in.ts deleted file mode 100644 index 1a077e9d0d783..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/endpoint_exceptions_per_policy_opt_in.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * 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. - */ - -export interface GetEndpointExceptionsPerPolicyOptInResponse { - status: boolean; -} 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 index 835ff7886f5ca..36e2eed047d77 100644 --- 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 @@ -6,7 +6,7 @@ */ import { ReservedPrivilegesSet, type RequestHandler } from '@kbn/core/server'; -import type { GetEndpointExceptionsPerPolicyOptInResponse } from '../../../../common/endpoint/types/endpoint_exceptions_per_policy_opt_in'; +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 88289fa90900fdccfcdde34e20fe59f4ff838fd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Tue, 24 Mar 2026 13:30:50 +0100 Subject: [PATCH 07/46] [ui] add callout component --- .../per_policy_opt_in_callout.test.tsx | 62 ++++++++++++++++ .../components/per_policy_opt_in_callout.tsx | 71 +++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/components/per_policy_opt_in_callout.test.tsx create mode 100644 x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/components/per_policy_opt_in_callout.tsx 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..0ac88e2e74f14 --- /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,62 @@ +/* + * 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(), + }; + + 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('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..7333caec3ae34 --- /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,71 @@ +/* + * 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, EuiSpacer } from '@elastic/eui'; +import React, { memo } from 'react'; +import { i18n } from '@kbn/i18n'; + +export interface EndpointExceptionsPerPolicyOptInCalloutProps { + onDismiss: () => void; + onClickUpdateDetails: () => void; +} + +export const EndpointExceptionsPerPolicyOptInCallout: React.FC = + memo(({ onDismiss, onClickUpdateDetails }) => { + return ( + + {i18n.translate( + 'xpack.securitySolution.endpointExceptions.perPolicyOptInCalloutDescription', + { + defaultMessage: + 'Endpoint exceptions can now be applied on a per-policy basis. Update existing Endpoint Exceptions to the policy-based model.', + } + )} + + + + + + {i18n.translate('xpack.securitySolution.endpointExceptions.perPolicyOptInCalloutCta', { + defaultMessage: 'Update details', + })} + + + + {i18n.translate( + 'xpack.securitySolution.endpointExceptions.perPolicyOptInCalloutLearnMore', + { + defaultMessage: 'Learn more', // TODO: Update with actual link to docs once available + } + )} + + + + ); + }); + +EndpointExceptionsPerPolicyOptInCallout.displayName = 'EndpointExceptionsPerPolicyOptInCallout'; From 41c6e06df75f349d6788f5a2987540623007d244 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Tue, 24 Mar 2026 13:31:16 +0100 Subject: [PATCH 08/46] [ui] add modal component --- .../per_policy_opt_in_modal.test.tsx | 74 ++++++++++ .../components/per_policy_opt_in_modal.tsx | 133 ++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/components/per_policy_opt_in_modal.test.tsx create mode 100644 x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/components/per_policy_opt_in_modal.tsx 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..23933bc9780bf --- /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,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 { + 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'; From b5a1c8e9713b38f9e466bf38836c4f7c1c260f58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Tue, 24 Mar 2026 14:15:48 +0100 Subject: [PATCH 09/46] [ui] add initial behavior in `usePerPolicyOptIn` --- .../use_endpoint_per_policy_opt_in.ts | 40 ++++++++ .../hooks/use_per_policy_opt_in.tsx | 95 +++++++++++++++++++ .../view/endpoint_exceptions.tsx | 29 +++--- 3 files changed, 153 insertions(+), 11 deletions(-) create mode 100644 x-pack/solutions/security/plugins/security_solution/public/management/hooks/artifacts/use_endpoint_per_policy_opt_in.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/hooks/use_per_policy_opt_in.tsx 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..fcf165d0d5870 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/management/hooks/artifacts/use_endpoint_per_policy_opt_in.ts @@ -0,0 +1,40 @@ +/* + * 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 { useMutation, useQuery } from '@kbn/react-query'; +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 useSendEndpointPerPolicyOptIn = () => { + const http = useHttp(); + + return useMutation({ + mutationFn: () => { + return http.post(ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE, { version: '1' }); + }, + }); +}; + +export const useGetEndpointPerPolicyOptIn = () => { + const http = useHttp(); + + return useQuery({ + queryKey: ['endpointExceptionsPerPolicyOptIn'], + queryFn: async () => { + try { + return ( + await http.get( + ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE, + { version: '1' } + ) + ).status; + } catch (error) { + return error; + } + }, + }); +}; 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..b615398ea0057 --- /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,95 @@ +/* + * 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 { 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 { + useGetEndpointPerPolicyOptIn, + useSendEndpointPerPolicyOptIn, +} from '../../../hooks/artifacts/use_endpoint_per_policy_opt_in'; + +const STORAGE_KEY = 'endpointExceptionsPerPolicyOptInCalloutDismissed'; + +export const usePerPolicyOptIn = (): { + perPolicyOptInCallout: React.ReactNode | null; + perPolicyOptInModal: React.ReactNode | null; +} => { + const { sessionStorage } = useKibana().services; + const toasts = useToasts(); + + const { mutate, isLoading } = useSendEndpointPerPolicyOptIn(); + const { data: isPerPolicyOptIn } = useGetEndpointPerPolicyOptIn(); + + const [isCalloutDismissed, setIsCalloutDismissed] = useState( + sessionStorage.get(STORAGE_KEY) === true + ); + const shouldShowCallout = isPerPolicyOptIn !== true && !isCalloutDismissed; + + 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); + setIsCalloutDismissed(true); + + 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, toasts]); + + return { + perPolicyOptInCallout: shouldShowCallout ? ( + + ) : null, + + perPolicyOptInModal: isModalVisible ? ( + + ) : null, + }; +}; 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..e05567baee02c 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,31 @@ 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 } = usePerPolicyOptIn(); + return ( - + <> + {perPolicyOptInModal} + + ); }); From bfdc38a8ab8fb7c063721334ca1bc1fef077cb60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Tue, 24 Mar 2026 14:22:06 +0100 Subject: [PATCH 10/46] [ui] add new `callout` prop to artifact list page --- .../management/components/administration_list_page.tsx | 2 +- .../components/artifact_list_page/artifact_list_page.tsx | 5 +++++ .../pages/endpoint_exceptions/view/endpoint_exceptions.tsx | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) 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..4a9f87b85b665 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 @@ -80,6 +80,7 @@ export interface ArtifactListPageProps { allowCardDeleteAction?: boolean; allowCardCreateAction?: boolean; secondaryPageInfo?: React.ReactNode; + callout?: React.ReactNode; CardDecorator?: React.ComponentType; } @@ -90,6 +91,7 @@ export const ArtifactListPage = memo( searchableFields = DEFAULT_EXCEPTION_LIST_ITEM_SEARCHABLE_FIELDS, labels: _labels = {}, secondaryPageInfo, + callout, onFormSubmit, flyoutSize, 'data-test-subj': dataTestSubj, @@ -417,6 +419,9 @@ export const ArtifactListPage = memo( /> ) : ( <> + {callout} + + { allowCardCreateAction={canWriteEndpointExceptions} allowCardEditAction={canWriteEndpointExceptions} allowCardDeleteAction={canWriteEndpointExceptions} - secondaryPageInfo={perPolicyOptInCallout} + callout={perPolicyOptInCallout} /> ); From 9f4d87fd405cc56ed566d8983debf1548fc6cf2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Tue, 24 Mar 2026 20:57:50 +0100 Subject: [PATCH 11/46] [ui] add unit test coverage --- .../common/lib/kibana/kibana_react.mock.ts | 9 +- .../use_endpoint_per_policy_opt_in.ts | 4 +- .../endpoint_per_policy_opt_in_http_mocks.ts | 54 ++++++ .../hooks/use_per_policy_opt_in.tsx | 8 +- .../components/per_policy_opt_in_callout.tsx | 1 + .../components/per_policy_opt_in_modal.tsx | 6 +- .../view/endpoint_exceptions.test.tsx | 167 +++++++++++++++++- 7 files changed, 232 insertions(+), 17 deletions(-) create mode 100644 x-pack/solutions/security/plugins/security_solution/public/management/mocks/endpoint_per_policy_opt_in_http_mocks.ts 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/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 index fcf165d0d5870..e2fdd379929d9 100644 --- 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 @@ -9,7 +9,7 @@ import type { GetEndpointExceptionsPerPolicyOptInResponse } from '../../../../co import { ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE } from '../../../../common/endpoint/constants'; import { useHttp } from '../../../common/lib/kibana'; -export const useSendEndpointPerPolicyOptIn = () => { +export const useSendEndpointExceptionsPerPolicyOptIn = () => { const http = useHttp(); return useMutation({ @@ -19,7 +19,7 @@ export const useSendEndpointPerPolicyOptIn = () => { }); }; -export const useGetEndpointPerPolicyOptIn = () => { +export const useGetEndpointExceptionsPerPolicyOptIn = () => { const http = useHttp(); return useQuery({ 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 index b615398ea0057..6624cbdef04b8 100644 --- 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 @@ -11,8 +11,8 @@ import { EndpointExceptionsPerPolicyOptInCallout } from '../view/components/per_ import { EndpointExceptionsPerPolicyOptInModal } from '../view/components/per_policy_opt_in_modal'; import { useKibana, useToasts } from '../../../../common/lib/kibana'; import { - useGetEndpointPerPolicyOptIn, - useSendEndpointPerPolicyOptIn, + useGetEndpointExceptionsPerPolicyOptIn, + useSendEndpointExceptionsPerPolicyOptIn, } from '../../../hooks/artifacts/use_endpoint_per_policy_opt_in'; const STORAGE_KEY = 'endpointExceptionsPerPolicyOptInCalloutDismissed'; @@ -24,8 +24,8 @@ export const usePerPolicyOptIn = (): { const { sessionStorage } = useKibana().services; const toasts = useToasts(); - const { mutate, isLoading } = useSendEndpointPerPolicyOptIn(); - const { data: isPerPolicyOptIn } = useGetEndpointPerPolicyOptIn(); + const { mutate, isLoading } = useSendEndpointExceptionsPerPolicyOptIn(); + const { data: isPerPolicyOptIn } = useGetEndpointExceptionsPerPolicyOptIn(); const [isCalloutDismissed, setIsCalloutDismissed] = useState( sessionStorage.get(STORAGE_KEY) === true 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 index 7333caec3ae34..e3e8eea4cd5d1 100644 --- 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 @@ -27,6 +27,7 @@ export const EndpointExceptionsPerPolicyOptInCallout: React.FC {i18n.translate( 'xpack.securitySolution.endpointExceptions.perPolicyOptInCalloutDescription', 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 index 23933bc9780bf..5ed1a1d53be1b 100644 --- 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 @@ -28,7 +28,11 @@ export interface EndpointExceptionsPerPolicyOptInModalProps { export const EndpointExceptionsPerPolicyOptInModal: React.FC = memo(({ onDismiss, onConfirm, isLoading }) => { return ( - + { it('should show the Empty message', async () => { render(); await waitFor(() => - expect(renderResult.getByTestId('endpointExceptionsListPage-emptyState')).toBeTruthy() + expect( + renderResult.getByTestId('endpointExceptionsListPage-emptyState') + ).toBeInTheDocument() ); }); }); @@ -62,7 +67,7 @@ describe('When on the endpoint exceptions page', () => { await waitFor(() => expect( renderResult.queryByTestId('endpointExceptionsListPage-emptyState-addButton') - ).toBeTruthy() + ).toBeInTheDocument() ); }); }); @@ -80,7 +85,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 +96,158 @@ 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'; + + 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, + }), + }); + }); + + 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 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 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(); + }); + }); + }); + }); }); From 7d97b5a8ea3d7f602f5e90e36dfd52a3d5d83ca3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Wed, 25 Mar 2026 09:53:31 +0100 Subject: [PATCH 12/46] [ui] RBAC: only admin can opt in --- .../endpoint/service/authz/authz.test.ts | 14 ++-- .../common/endpoint/service/authz/authz.ts | 4 + .../common/endpoint/types/authz.ts | 3 + .../hooks/use_per_policy_opt_in.tsx | 3 + .../per_policy_opt_in_callout.test.tsx | 28 +++++++ .../components/per_policy_opt_in_callout.tsx | 75 +++++++++++-------- .../view/endpoint_exceptions.test.tsx | 40 ++++++++++ .../endpoint_exceptions_per_policy_opt_in.ts | 3 + 8 files changed, 132 insertions(+), 38 deletions(-) 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..222ab1fb07721 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 @@ -322,11 +322,14 @@ 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'} + privilege | expectedResult | roles | description + ${'canReadAdminData'} | ${true} | ${['superuser', 'role-2']} | ${'user has superuser role'} + ${'canWriteAdminData'} | ${true} | ${['superuser', 'role-2']} | ${'user has superuser role'} + ${'canOptInPerPolicyEndpointExceptions'} | ${true} | ${['superuser', 'role-2']} | ${'user has superuser role'} + ${'canOptInPerPolicyEndpointExceptions'} | ${true} | ${['admin', 'role-2']} | ${'user has admin role'} + ${'canReadAdminData'} | ${false} | ${['role-2']} | ${'user does NOT have superuser role'} + ${'canWriteAdminData'} | ${false} | ${['role-2']} | ${'user does NOT superuser role'} + ${'canOptInPerPolicyEndpointExceptions'} | ${false} | ${['role-2']} | ${'user does NOT have superuser role'} `( 'should set `$privilege` to `$expectedResult` when $description', ({ privilege, expectedResult, roles }) => { @@ -385,6 +388,7 @@ describe('Endpoint Authz service', () => { canReadEventFilters: false, canReadEndpointExceptions: false, canWriteEndpointExceptions: false, + canOptInPerPolicyEndpointExceptions: false, canReadAdminData: false, canWriteAdminData: false, canReadScriptsLibrary: false, 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..5fc890a2e3b43 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 @@ -67,6 +67,7 @@ export const calculateEndpointAuthz = ( ): EndpointAuthz => { const hasAuth = hasAuthFactory(fleetAuthz, productFeaturesService); const hasSuperuserRole = userRoles.includes('superuser'); + const hasAdminRole = userRoles.includes('admin'); const isPlatinumPlusLicense = licenseService.isPlatinumPlus(); const isEnterpriseLicense = licenseService.isEnterprise(); @@ -102,6 +103,7 @@ export const calculateEndpointAuthz = ( const canReadEndpointExceptions = hasAuth('showEndpointExceptions'); const canWriteEndpointExceptions = hasAuth('crudEndpointExceptions'); + const canOptInPerPolicyEndpointExceptions = hasSuperuserRole || hasAdminRole; const canManageGlobalArtifacts = hasAuth('writeGlobalArtifacts'); @@ -180,6 +182,7 @@ export const calculateEndpointAuthz = ( canReadEventFilters, canReadEndpointExceptions, canWriteEndpointExceptions, + canOptInPerPolicyEndpointExceptions, canManageGlobalArtifacts, // --------------------------------------------------------- @@ -255,6 +258,7 @@ export const getEndpointAuthzInitialState = (): EndpointAuthz => { canReadEventFilters: false, canReadEndpointExceptions: false, canWriteEndpointExceptions: false, + canOptInPerPolicyEndpointExceptions: false, canManageGlobalArtifacts: false, canReadWorkflowInsights: false, canWriteWorkflowInsights: false, 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..17424b032e737 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,9 @@ export interface EndpointAuthz { canReadEndpointExceptions: boolean; /** if the user has read permissions for endpoint exceptions */ canWriteEndpointExceptions: boolean; + /** if the user has permissions to opt-in to per-policy endpoint exceptions */ + canOptInPerPolicyEndpointExceptions: 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/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 index 6624cbdef04b8..0390d1d2f22e1 100644 --- 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 @@ -7,6 +7,7 @@ import React, { useCallback, useState } from 'react'; import { i18n } from '@kbn/i18n'; +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'; @@ -23,6 +24,7 @@ export const usePerPolicyOptIn = (): { } => { const { sessionStorage } = useKibana().services; const toasts = useToasts(); + const { canOptInPerPolicyEndpointExceptions } = useUserPrivileges().endpointPrivileges; const { mutate, isLoading } = useSendEndpointExceptionsPerPolicyOptIn(); const { data: isPerPolicyOptIn } = useGetEndpointExceptionsPerPolicyOptIn(); @@ -81,6 +83,7 @@ export const usePerPolicyOptIn = (): { ) : null, 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 index 0ac88e2e74f14..24ee4f1b3fc0f 100644 --- 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 @@ -22,6 +22,7 @@ describe('EndpointExceptionsPerPolicyOptInCallout', () => { props = { onDismiss: jest.fn(), onClickUpdateDetails: jest.fn(), + canOptIn: true, }; const mockedContext = createAppRootMockRenderer(); @@ -38,6 +39,33 @@ describe('EndpointExceptionsPerPolicyOptInCallout', () => { 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.getByTestId( + 'updateDetailsEndpointExceptionsPerPolicyOptInButton' + ); + expect(updateDetailsButton).not.toBeInTheDocument(); + }); + it('calls onClickUpdateDetails when update details button is clicked', () => { render(); 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 index e3e8eea4cd5d1..deea8e8b91b9f 100644 --- 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 @@ -8,14 +8,16 @@ import { EuiButton, EuiCallOut, EuiFlexGroup, EuiSpacer } from '@elastic/eui'; import React, { memo } from 'react'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; export interface EndpointExceptionsPerPolicyOptInCalloutProps { onDismiss: () => void; onClickUpdateDetails: () => void; + canOptIn: boolean; } export const EndpointExceptionsPerPolicyOptInCallout: React.FC = - memo(({ onDismiss, onClickUpdateDetails }) => { + memo(({ onDismiss, onClickUpdateDetails, canOptIn }) => { return ( - {i18n.translate( - 'xpack.securitySolution.endpointExceptions.perPolicyOptInCalloutDescription', - { - defaultMessage: - 'Endpoint exceptions can now be applied on a per-policy basis. Update existing Endpoint Exceptions to the policy-based model.', - } - )} + - - - {i18n.translate('xpack.securitySolution.endpointExceptions.perPolicyOptInCalloutCta', { - defaultMessage: 'Update details', - })} - + {canOptIn ? ( + + + {i18n.translate( + 'xpack.securitySolution.endpointExceptions.perPolicyOptInCalloutCta', + { + defaultMessage: 'Update details', + } + )} + - - {i18n.translate( - 'xpack.securitySolution.endpointExceptions.perPolicyOptInCalloutLearnMore', - { - defaultMessage: 'Learn more', // TODO: Update with actual link to docs once available - } - )} - - + + {i18n.translate( + 'xpack.securitySolution.endpointExceptions.perPolicyOptInCalloutLearnMore', + { + defaultMessage: 'Learn more', // TODO: Update with actual link to docs once available + } + )} + + + ) : ( + + )} ); }); 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 13f7a47c5093e..99a08dc57aa01 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 @@ -57,6 +57,7 @@ describe('When on the endpoint exceptions page', () => { mockUserPrivileges.mockReturnValue({ endpointPrivileges: getEndpointAuthzInitialStateMock({ canWriteEndpointExceptions: true, + canOptInPerPolicyEndpointExceptions: false, }), }); }); @@ -77,6 +78,7 @@ describe('When on the endpoint exceptions page', () => { mockUserPrivileges.mockReturnValue({ endpointPrivileges: getEndpointAuthzInitialStateMock({ canWriteEndpointExceptions: false, + canOptInPerPolicyEndpointExceptions: false, }), }); }); @@ -123,6 +125,7 @@ describe('When on the endpoint exceptions page', () => { mockUserPrivileges.mockReturnValue({ endpointPrivileges: getEndpointAuthzInitialStateMock({ canWriteEndpointExceptions: true, + canOptInPerPolicyEndpointExceptions: true, }), }); }); @@ -184,6 +187,43 @@ describe('When on the endpoint exceptions page', () => { }); }); + describe('RBAC', () => { + it('should show the update details button if user has superuser role', async () => { + mockUserPrivileges.mockReturnValue({ + endpointPrivileges: getEndpointAuthzInitialStateMock({ + canWriteEndpointExceptions: true, + canOptInPerPolicyEndpointExceptions: true, + }), + }); + + render(); + + await waitFor(() => { + expect(renderResult.queryByTestId(CALLOUT)).toBeInTheDocument(); + expect(renderResult.queryByTestId(UPDATE_DETAILS_BTN)).toBeInTheDocument(); + }); + }); + + it('should show "Contact your admin" if user does not have superuser role', async () => { + mockUserPrivileges.mockReturnValue({ + endpointPrivileges: getEndpointAuthzInitialStateMock({ + canWriteEndpointExceptions: true, + canOptInPerPolicyEndpointExceptions: false, + }), + }); + + 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(); + }); + }); + }); + describe('when showing the opt-in modal', () => { it('should show the modal when clicking on the update details button', async () => { render(); 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 index 36e2eed047d77..ddb4ba4707f52 100644 --- 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 @@ -91,6 +91,9 @@ export const registerEndpointExceptionsPerPolicyOptInRoute = ( version: '1', validate: {}, }, + // todo: would be better to use `withEndpointAuthz` with `canOptInPerPolicyEndpointExceptions`, + // 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. getOptInToPerPolicyEndpointExceptionsPOSTHandler(endpointContext.service) ); From 06645916caaa06ae8e0be90929cf61e7a3a6d2f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Wed, 25 Mar 2026 12:19:05 +0100 Subject: [PATCH 13/46] [ui] show opt-in menu action to user --- .../artifact_list_page/artifact_list_page.tsx | 56 +++++--- .../components/artifact_list_page/mocks.tsx | 9 +- .../hooks/use_per_policy_opt_in.tsx | 21 ++- .../view/endpoint_exceptions.test.tsx | 126 ++++++++++++++---- .../view/endpoint_exceptions.tsx | 6 +- 5 files changed, 166 insertions(+), 52 deletions(-) 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 4a9f87b85b665..1ee3d94eb9cd3 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/src/header_menu'; import { AutoDownload } from '../../../common/components/auto_download/auto_download'; import type { ServerApiError } from '../../../common/types'; import { AdministrationListPage } from '../administration_list_page'; @@ -82,6 +83,7 @@ export interface ArtifactListPageProps { secondaryPageInfo?: React.ReactNode; callout?: React.ReactNode; CardDecorator?: React.ComponentType; + additionalActions?: Action[]; } export const ArtifactListPage = memo( @@ -99,6 +101,7 @@ export const ArtifactListPage = memo( allowCardCreateAction = true, allowCardDeleteAction = true, CardDecorator, + additionalActions, }) => { const areEndpointExceptionsMovedUnderManagementFFEnabled = useIsExperimentalFeatureEnabled( 'endpointExceptionsMovedUnderManagement' @@ -313,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 ; } @@ -337,25 +373,11 @@ export const ArtifactListPage = memo( )} - {areEndpointExceptionsMovedUnderManagementFFEnabled && ( + {actionsToDisplay.length > 0 && ( )} diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/mocks.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/mocks.tsx index f7d9000f92bf2..a9aefa1e40168 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/mocks.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/artifact_list_page/mocks.tsx @@ -150,14 +150,13 @@ export const getArtifactImportExportUiMocks = ( renderResult: ReturnType, 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/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 index 0390d1d2f22e1..2a0a39bda14c8 100644 --- 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 @@ -7,6 +7,7 @@ import React, { useCallback, useState } from 'react'; import { i18n } from '@kbn/i18n'; +import type { Action } from '@kbn/securitysolution-exception-list-components/src/header_menu'; 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'; @@ -21,18 +22,20 @@ 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 { canOptInPerPolicyEndpointExceptions } = useUserPrivileges().endpointPrivileges; const { mutate, isLoading } = useSendEndpointExceptionsPerPolicyOptIn(); - const { data: isPerPolicyOptIn } = useGetEndpointExceptionsPerPolicyOptIn(); + const { data: isPerPolicyOptIn, refetch } = useGetEndpointExceptionsPerPolicyOptIn(); const [isCalloutDismissed, setIsCalloutDismissed] = useState( sessionStorage.get(STORAGE_KEY) === true ); const shouldShowCallout = isPerPolicyOptIn !== true && !isCalloutDismissed; + const shouldShowAction = isPerPolicyOptIn !== true && canOptInPerPolicyEndpointExceptions; const [isModalVisible, setIsModalVisible] = useState(false); @@ -53,7 +56,7 @@ export const usePerPolicyOptIn = (): { mutate(undefined, { onSuccess: () => { setIsModalVisible(false); - setIsCalloutDismissed(true); + refetch(); toasts.addSuccess({ title: i18n.translate( @@ -76,7 +79,7 @@ export const usePerPolicyOptIn = (): { }); }, }); - }, [mutate, toasts]); + }, [mutate, refetch, toasts]); return { perPolicyOptInCallout: shouldShowCallout ? ( @@ -94,5 +97,17 @@ export const usePerPolicyOptIn = (): { isLoading={isLoading} /> ) : 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/endpoint_exceptions.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_exceptions/view/endpoint_exceptions.test.tsx index 99a08dc57aa01..8f7f2286e0d6e 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 @@ -29,6 +29,8 @@ describe('When on the endpoint exceptions page', () => { beforeEach(() => { mockedContext = createAppRootMockRenderer(); ({ history } = mockedContext); + mockedContext.setExperimentalFlag({ endpointExceptionsMovedUnderManagement: true }); + render = () => (renderResult = mockedContext.render()); act(() => { @@ -106,6 +108,9 @@ describe('When on the endpoint exceptions page', () => { 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 @@ -187,40 +192,40 @@ describe('When on the endpoint exceptions page', () => { }); }); - describe('RBAC', () => { - it('should show the update details button if user has superuser role', async () => { - mockUserPrivileges.mockReturnValue({ - endpointPrivileges: getEndpointAuthzInitialStateMock({ - canWriteEndpointExceptions: true, - canOptInPerPolicyEndpointExceptions: true, - }), - }); + 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(renderResult.queryByTestId(CALLOUT)).toBeInTheDocument(); - expect(renderResult.queryByTestId(UPDATE_DETAILS_BTN)).toBeInTheDocument(); - }); + 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 show "Contact your admin" if user does not have superuser role', async () => { - mockUserPrivileges.mockReturnValue({ - endpointPrivileges: getEndpointAuthzInitialStateMock({ - canWriteEndpointExceptions: true, - canOptInPerPolicyEndpointExceptions: false, - }), - }); + 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(() => { - expect(renderResult.queryByTestId(CALLOUT)).toBeInTheDocument(); - expect( - renderResult.queryByText(/Contact your administrator to update details/) - ).toBeInTheDocument(); - expect(renderResult.queryByTestId(UPDATE_DETAILS_BTN)).not.toBeInTheDocument(); - }); + await waitFor(() => userEvent.click(renderResult.getByTestId(MENU_BTN))); + + expect( + renderResult.queryByTestId(UPDATE_TO_PER_POLICY_ACTION_BTN) + ).not.toBeInTheDocument(); }); }); @@ -288,6 +293,75 @@ describe('When on the endpoint exceptions page', () => { expect(renderResult.queryByTestId(MODAL)).toBeInTheDocument(); }); }); + + describe('RBAC', () => { + describe('when user has the `canOptInPerPolicyEndpointExceptions` privilege', () => { + beforeEach(() => { + mockUserPrivileges.mockReturnValue({ + endpointPrivileges: getEndpointAuthzInitialStateMock({ + canWriteEndpointExceptions: true, + canOptInPerPolicyEndpointExceptions: 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 `canOptInPerPolicyEndpointExceptions` privilege', () => { + beforeEach(() => { + mockUserPrivileges.mockReturnValue({ + endpointPrivileges: getEndpointAuthzInitialStateMock({ + canWriteEndpointExceptions: true, + canOptInPerPolicyEndpointExceptions: 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 d283143a93ead..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 @@ -21,7 +21,8 @@ export const EndpointExceptions = memo(() => { const http = useHttp(); const endpointExceptionsApiClient = EndpointExceptionsApiClient.getInstance(http); - const { perPolicyOptInCallout, perPolicyOptInModal } = usePerPolicyOptIn(); + const { perPolicyOptInCallout, perPolicyOptInModal, perPolicyOptInActionMenuItem } = + usePerPolicyOptIn(); return ( <> @@ -37,6 +38,9 @@ export const EndpointExceptions = memo(() => { allowCardEditAction={canWriteEndpointExceptions} allowCardDeleteAction={canWriteEndpointExceptions} callout={perPolicyOptInCallout} + additionalActions={ + perPolicyOptInActionMenuItem ? [perPolicyOptInActionMenuItem] : undefined + } /> ); From 9127f15462cc0d4adcf0c0802b42fbb34457f7e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Wed, 25 Mar 2026 16:13:02 +0100 Subject: [PATCH 14/46] [e2e] add cypress happy-path test --- .../e2e/artifacts/endpoint_exceptions.cy.ts | 139 ++++++++++++------ .../management/cypress/tasks/artifacts.ts | 24 +++ 2 files changed, 121 insertions(+), 42 deletions(-) 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/tasks/artifacts.ts b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/tasks/artifacts.ts index 733b93153e6c4..c4e06a61e4a53 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,25 @@ 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 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, + }); +}; From fc9231460e9b8bdb0be7aefc7614d9680dd69707 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Wed, 25 Mar 2026 16:13:44 +0100 Subject: [PATCH 15/46] [e2e] perform opt-in in existing endpoint exceptions cy test --- .../management/cypress/support/artifacts_rbac_runner.ts | 3 +++ .../public/management/cypress/tasks/artifacts.ts | 8 ++++++++ 2 files changed, 11 insertions(+) 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 c4e06a61e4a53..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 @@ -453,6 +453,14 @@ export const fetchEndpointExceptionPerPolicyOptInStatus = (): Cypress.Chainable< 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'; From a897beae16155564ad6f1edb3bd4230cda4adf08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Wed, 25 Mar 2026 21:03:45 +0100 Subject: [PATCH 16/46] [ui] hiding policy selector on endpoint exception form --- .../endpoint_exceptions_form.test.tsx | 20 +++++++++++++++++++ .../components/endpoint_exceptions_form.tsx | 11 +++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) 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..242466c1c1d10 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,8 @@ 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'; jest.setTimeout(15_000); @@ -30,6 +32,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 +159,9 @@ describe('Endpoint exceptions form', () => { ] as PackagePolicy[], }, }); + mockedUseGetEndpointExceptionsPerPolicyOptIn.mockReturnValue({ + data: true, + } as UseQueryResult); formProps = { item: latestUpdatedItem, @@ -507,6 +518,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: 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()); 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..9a833acf3e74a 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,15 @@ export const EndpointExceptionsForm: React.FC = mem hasPartialCodeSignatureEntry([exception]) ); - const showAssignmentSection = useCanAssignArtifactPerPolicy(exception, mode, hasFormChanged); + const { data: isEndpointExceptionsPerPolicyOptedIn } = useGetEndpointExceptionsPerPolicyOptIn(); + + const canAssignArtifactPerPolicy = useCanAssignArtifactPerPolicy( + exception, + mode, + hasFormChanged + ); + const showAssignmentSection = + isEndpointExceptionsPerPolicyOptedIn === true && canAssignArtifactPerPolicy; const [isIndexPatternLoading, { indexPatterns }] = useFetchIndex( ENDPOINT_ALERTS_INDEX_NAMES, From eb3ea95cb9b130f9210063463f58bd20dcd40dbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Wed, 25 Mar 2026 21:40:25 +0100 Subject: [PATCH 17/46] [ui] hiding policy assignment in policy details for endpoint exceptions --- .../artifacts/layout/policy_artifacts_layout.tsx | 13 +++++++++---- .../pages/policy/view/tabs/policy_tabs.tsx | 5 +++++ 2 files changed, 14 insertions(+), 4 deletions(-) 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: isEndpointExceptionsPerPolicyOptedIn } = 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={!isEndpointExceptionsPerPolicyOptedIn} /> ), @@ -532,6 +536,7 @@ export const PolicyTabs = React.memo(() => { canReadEndpointExceptions, getEndpointExceptionsApiClientInstance, canWriteEndpointExceptions, + isEndpointExceptionsPerPolicyOptedIn, isEnterprise, ]); From 5f02fd91a7b7ae1e41772e9f8c3fefd583f38d90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Thu, 26 Mar 2026 13:21:53 +0100 Subject: [PATCH 18/46] [api] update create/update apis with opt-in status --- .../endpoint/common/per_policy_opt_in.ts | 57 +++++++++++++++++++ .../endpoint/endpoint_app_context_services.ts | 27 ++++++++- .../handlers/exceptions_pre_create_handler.ts | 4 +- .../handlers/exceptions_pre_update_handler.ts | 6 +- .../endpoint_exceptions_validator.ts | 10 ++-- .../endpoint_exceptions.ts | 31 +++++++++- .../endpoint_exceptions_per_policy_opt_in.ts | 55 +++++------------- 7 files changed, 136 insertions(+), 54 deletions(-) create mode 100644 x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/per_policy_opt_in.ts diff --git a/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/per_policy_opt_in.ts b/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/per_policy_opt_in.ts new file mode 100644 index 0000000000000..250dd1cbf2e71 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/per_policy_opt_in.ts @@ -0,0 +1,57 @@ +/* + * 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 { KbnClient } from '@kbn/kbn-client'; +import type { SavedObjectsFindResult } from '@kbn/core/server'; +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 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', + }, + }); +}; 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..cea8ab205f200 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'; @@ -502,6 +502,29 @@ export class EndpointAppContextService { ); } + /** + * 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 endpointExceptionsMovedUnderManagement = + await referenceDataClient.get( + REF_DATA_KEYS.endpointExceptionsPerPolicyOptInStatus + ); + + return endpointExceptionsMovedUnderManagement.metadata.status; + } + public getServerConfigValue( key: TKey ): ConfigType[TKey] { 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_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/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..df2920fdebdd4 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 { + deleteEndpointExceptionsPerPolicyOptInSO, + optInForPerPolicyEndpointExceptions, +} from '@kbn/security-solution-plugin/scripts/endpoint/common/per_policy_opt_in'; 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 deleteEndpointExceptionsPerPolicyOptInSO(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(); 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 index 2275d8d72cd77..86c79066fcb8d 100644 --- 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 @@ -8,14 +8,10 @@ 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 type { - OptInStatusMetadata, - ReferenceDataSavedObject, -} from '@kbn/security-solution-plugin/server/endpoint/lib/reference_data'; import { - REF_DATA_KEYS, - REFERENCE_DATA_SAVED_OBJECT_TYPE, -} from '@kbn/security-solution-plugin/server/endpoint/lib/reference_data'; + deleteEndpointExceptionsPerPolicyOptInSO, + findEndpointExceptionsPerPolicyOptInSO, +} from '@kbn/security-solution-plugin/scripts/endpoint/common/per_policy_opt_in'; import type { CustomRole } from '../../../../config/services/types'; import type { FtrProviderContext } from '../../../../ftr_provider_context_edr_workflows'; @@ -33,42 +29,15 @@ export default function endpointExceptionsPerPolicyOptInTests({ getService }: Ft .find((s) => s.startsWith('--xpack.securitySolution.enableExperimental')) ?.includes('endpointExceptionsMovedUnderManagement'); - const OPT_IN_STATUS_DESCRIPTOR = { - type: REFERENCE_DATA_SAVED_OBJECT_TYPE, - id: REF_DATA_KEYS.endpointExceptionsPerPolicyOptInStatus, - }; - const HEADERS = { 'x-elastic-internal-origin': 'kibana', 'Elastic-Api-Version': '1', 'kbn-xsrf': 'true', }; - const findOptInStatusSO = async (): Promise< - ReferenceDataSavedObject | undefined - > => { - const foundReferenceObjects = await kibanaServer.savedObjects.find({ - type: REFERENCE_DATA_SAVED_OBJECT_TYPE, - }); - - return foundReferenceObjects.saved_objects.find( - (obj) => obj.id === REF_DATA_KEYS.endpointExceptionsPerPolicyOptInStatus - )?.attributes as ReferenceDataSavedObject | undefined; - }; - describe('@ess @serverless @skipInServerlessMKI Endpoint Exceptions Per Policy Opt-In API', function () { beforeEach(async () => { - const foundReferenceDataSavedObjects = await kibanaServer.savedObjects.find({ - type: REFERENCE_DATA_SAVED_OBJECT_TYPE, - }); - - if ( - foundReferenceDataSavedObjects.saved_objects.find( - (obj) => obj.id === REF_DATA_KEYS.endpointExceptionsPerPolicyOptInStatus - ) - ) { - await kibanaServer.savedObjects.delete(OPT_IN_STATUS_DESCRIPTOR); - } + await deleteEndpointExceptionsPerPolicyOptInSO(kibanaServer); }); if (IS_ENDPOINT_EXCEPTION_MOVE_FF_ENABLED) { @@ -107,7 +76,9 @@ export default function endpointExceptionsPerPolicyOptInTests({ getService }: Ft describe('functionality', () => { it('should store the opt-in status in reference data', async () => { - const initialOptInStatusSO = await findOptInStatusSO(); + const initialOptInStatusSO = await findEndpointExceptionsPerPolicyOptInSO( + kibanaServer + ); expect(initialOptInStatusSO).to.be(undefined); await superuser @@ -115,12 +86,14 @@ export default function endpointExceptionsPerPolicyOptInTests({ getService }: Ft .set(HEADERS) .expect(200); - const optInStatusSO = await findOptInStatusSO(); - expect(optInStatusSO?.metadata.status).to.be(true); + const optInStatusSO = await findEndpointExceptionsPerPolicyOptInSO(kibanaServer); + expect(optInStatusSO?.attributes.metadata.status).to.be(true); }); it('should have an idempotent behavior', async () => { - const initialOptInStatusSO = await findOptInStatusSO(); + const initialOptInStatusSO = await findEndpointExceptionsPerPolicyOptInSO( + kibanaServer + ); expect(initialOptInStatusSO).to.be(undefined); await superuser @@ -132,8 +105,8 @@ export default function endpointExceptionsPerPolicyOptInTests({ getService }: Ft .set(HEADERS) .expect(200); - const optInStatusSO = await findOptInStatusSO(); - expect(optInStatusSO?.metadata.status).to.be(true); + const optInStatusSO = await findEndpointExceptionsPerPolicyOptInSO(kibanaServer); + expect(optInStatusSO?.attributes.metadata.status).to.be(true); }); }); }); From 82412cf0c6705732c5c139ebe3396ba9fa843622 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Thu, 26 Mar 2026 16:28:34 +0100 Subject: [PATCH 19/46] [api] update import api with opt-in status --- .../handlers/exceptions_pre_import_handler.ts | 44 ++- .../artifact_import.ts | 276 ++++++++++-------- 2 files changed, 190 insertions(+), 130 deletions(-) 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/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..c4e98d5a2af72 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 { + deleteEndpointExceptionsPerPolicyOptInSO, + optInForPerPolicyEndpointExceptions, +} from '@kbn/security-solution-plugin/scripts/endpoint/common/per_policy_opt_in'; 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 deleteEndpointExceptionsPerPolicyOptInSO(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 deleteEndpointExceptionsPerPolicyOptInSO(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 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); + }); + }); }); } From ca628fb9c40da944fbb772278465faa988d30864 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Thu, 26 Mar 2026 18:48:24 +0100 Subject: [PATCH 20/46] [task] update ManifestManager with opt-in status --- .../endpoint/lib/reference_data/helpers.ts | 25 +++++++++++++++++++ .../endpoint/lib/reference_data/index.ts | 1 + .../reference_data/reference_data_client.ts | 3 +-- .../manifest_manager/manifest_manager.test.ts | 24 +++++++++++------- .../manifest_manager/manifest_manager.ts | 15 +++++++++-- 5 files changed, 55 insertions(+), 13 deletions(-) create mode 100644 x-pack/solutions/security/plugins/security_solution/server/endpoint/lib/reference_data/helpers.ts 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..68d1fcba3f664 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/lib/reference_data/helpers.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. + */ + +import type { SavedObjectsClientContract } from '@kbn/core/server'; +import type { Logger } from '@kbn/logging'; +import { ReferenceDataClient } from './reference_data_client'; +import { REF_DATA_KEYS } from './constants'; +import type { OptInStatusMetadata } from './types'; + +export const getIsEndpointExceptionsPerPolicyEnabled = async ( + soClient: SavedObjectsClientContract, + logger: Logger +): Promise => { + const referenceDataClient = new ReferenceDataClient(soClient, logger); + + const optInStatus = await referenceDataClient.get( + REF_DATA_KEYS.endpointExceptionsPerPolicyOptInStatus + ); + + return optInStatus.metadata.status; +}; 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/reference_data_client.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/lib/reference_data/reference_data_client.ts index 4e99b1061eb0a..4566af187925d 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 @@ -15,7 +15,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'; /** @@ -64,7 +63,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) => { 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..44b6175b6aaf4 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,10 @@ describe('ManifestManager', () => { defaultFeatures = allowedExperimentalValues; }); + beforeEach(() => { + mockedGetIsEndpointExceptionsPerPolicyEnabled.mockResolvedValue(false); + }); + describe('getLastComputedManifest from Unified Manifest SO', () => { const mockGetAllUnifiedManifestsSOFromCache = jest.fn().mockImplementation(() => [ { @@ -1258,12 +1270,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); }); @@ -1295,10 +1304,7 @@ describe('ManifestManager', () => { 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); }); 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..6d5b5b8892d2d 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[]; @@ -232,7 +233,12 @@ export class ManifestManager { let exceptions: ExceptionListItemSchema[]; - if (this.experimentalFeatures.endpointExceptionsMovedUnderManagement) { + if ( + await getIsEndpointExceptionsPerPolicyEnabled( + this.savedObjectsClientFactory.createInternalScopedSoClient({ readonly: false }), + this.logger + ) + ) { // with the feature enabled, we do not make an 'exception' with endpoint exceptions - it's filtered per-policy exceptions = allExceptionsByListId.filter(filter); } else { @@ -337,7 +343,12 @@ export class ManifestManager { let policySpecificArtifacts: Record = {}; - if (this.experimentalFeatures.endpointExceptionsMovedUnderManagement) { + if ( + await getIsEndpointExceptionsPerPolicyEnabled( + this.savedObjectsClientFactory.createInternalScopedSoClient({ readonly: false }), + this.logger + ) + ) { policySpecificArtifacts = await this.buildArtifactsByPolicy( allPolicyIds, ArtifactConstants.SUPPORTED_ENDPOINT_EXCEPTIONS_OPERATING_SYSTEMS, From 2165f673ea57b027d3688cd496096ed790230f15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Fri, 27 Mar 2026 08:20:18 +0100 Subject: [PATCH 21/46] [ui] hide "Add existing endpont exceptions" to rule checkbox based on opt-in status --- .../components/step_about_rule/index.test.tsx | 30 +++++++++++++++---- .../components/step_about_rule/index.tsx | 8 ++--- 2 files changed, 27 insertions(+), 11 deletions(-) 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..eefa052fe269f 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,8 @@ describe.skip('StepAboutRuleComponent', () => { jobs: [], })); useSecurityJobsMock = (useSecurityJobs as jest.Mock).mockImplementation(() => ({ jobs: [] })); + + mockedUseGetEndpointExceptionsPerPolicyOptIn.mockImplementation(() => ({ data: false })); }); it('it renders StepRuleDescription if isReadOnlyView is true and "name" property exists', () => { @@ -149,7 +150,24 @@ 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: undefined })); + + 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: 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..87e463b06988a 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: isEndpointExceptionsPerPolicyEnabled } = useGetEndpointExceptionsPerPolicyOptIn(); useEffect(() => { if (index != null && (dataViewId === '' || dataViewId == null)) { @@ -338,7 +336,7 @@ const StepAboutRuleComponent: FC = ({ /> - {!endpointExceptionsMovedUnderManagement ? ( + {!isEndpointExceptionsPerPolicyEnabled ? ( Date: Fri, 27 Mar 2026 09:00:56 +0100 Subject: [PATCH 22/46] [DE] do not evaluate endpoint exceptions based on opt-in status --- .../security_solution/server/endpoint/mocks/mocks.ts | 1 + .../rule_types/create_security_rule_type_wrapper.ts | 3 ++- .../rule_types/query/create_query_alert_type.test.ts | 2 ++ .../server/lib/detection_engine/rule_types/types.ts | 2 ++ .../security/plugins/security_solution/server/plugin.ts | 1 + .../create_endpoint_exceptions.ts | 9 +++++++++ 6 files changed, 17 insertions(+), 1 deletion(-) 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/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 13a3f2c08efbb..3f36ea53bbf28 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 1c0cb094b1527..3eebd0fb21def 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'; @@ -159,6 +160,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/plugin.ts b/x-pack/solutions/security/plugins/security_solution/server/plugin.ts index f3d4539153884..8ba30190dcac1 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/plugin.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/plugin.ts @@ -491,6 +491,7 @@ export class Plugin implements ISecuritySolutionPlugin { endpointAppContextService: this.endpointAppContextService, osqueryCreateActionService: plugins.osquery?.createActionService, }), + endpointAppContextService: this.endpointAppContextService, }; const securityRuleTypeWrapper = createSecurityRuleTypeWrapper(securityRuleTypeOptions); diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/workflows/basic_license_essentials_tier/create_endpoint_exceptions.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/workflows/basic_license_essentials_tier/create_endpoint_exceptions.ts index 6c53077a0c489..1f27ebaa580f4 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/workflows/basic_license_essentials_tier/create_endpoint_exceptions.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/workflows/basic_license_essentials_tier/create_endpoint_exceptions.ts @@ -19,6 +19,10 @@ import { waitForRuleSuccess, waitForAlertsToBePresent, } from '@kbn/detections-response-ftr-services'; +import { + deleteEndpointExceptionsPerPolicyOptInSO, + optInForPerPolicyEndpointExceptions, +} from '@kbn/security-solution-plugin/scripts/endpoint/common/per_policy_opt_in'; import { createRuleWithExceptionEntries } from '../../../../utils'; import { createListsIndex, @@ -80,6 +84,7 @@ export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); const log = getService('log'); const es = getService('es'); + const kibanaServer = getService('kibanaServer'); describe('@serverless @serverlessQA @ess @skipInServerlessMKI create_endpoint_exceptions', () => { before(async () => { @@ -89,6 +94,8 @@ export default ({ getService }: FtrProviderContext) => { await esArchiver.load( 'x-pack/solutions/security/test/fixtures/es_archives/rule_exceptions/agent' ); + + await optInForPerPolicyEndpointExceptions(kibanaServer); }); after(async () => { @@ -98,6 +105,8 @@ export default ({ getService }: FtrProviderContext) => { await esArchiver.unload( 'x-pack/solutions/security/test/fixtures/es_archives/rule_exceptions/agent' ); + + await deleteEndpointExceptionsPerPolicyOptInSO(kibanaServer); }); beforeEach(async () => { From 81e24a7c8261939fc8797bf3d5bb59f6fad5a322 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Fri, 27 Mar 2026 15:18:32 +0100 Subject: [PATCH 23/46] [data] initialize opt-in status based on new deployment vs upgrade --- ...dpoint_exceptions_per_policy_opt_in.gen.ts | 1 + ...t_exceptions_per_policy_opt_in.schema.yaml | 3 + .../endpoint/common/per_policy_opt_in.ts | 14 ++ .../endpoint/endpoint_app_context_services.ts | 1 + .../endpoint/lib/reference_data/constants.ts | 128 ++++++++++++------ .../endpoint/lib/reference_data/helpers.ts | 38 +++++- .../endpoint/lib/reference_data/mocks.ts | 6 +- .../reference_data_client.test.ts | 13 +- .../reference_data/reference_data_client.ts | 7 +- .../endpoint/lib/reference_data/types.ts | 1 + .../orphan_actions_space_handler.test.ts | 8 +- .../endpoint_exceptions_per_policy_opt_in.ts | 6 +- .../utils/fetch_action_request_by_id.test.ts | 12 +- .../utils/fetch_action_requests.test.ts | 12 +- .../manifest_manager/manifest_manager.ts | 2 + .../security_solution/server/plugin.ts | 12 ++ .../create_endpoint_exceptions.ts | 4 +- .../artifact_import.ts | 8 +- ...endpoint_exceptions_moved_ff.ess.config.ts | 8 ++ .../endpoint_exceptions.ts | 4 +- .../endpoint_exceptions_per_policy_opt_in.ts | 38 +++++- 21 files changed, 252 insertions(+), 74 deletions(-) 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 index e2c25f41236b5..b3417407d3b54 100644 --- 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 @@ -21,4 +21,5 @@ export type GetEndpointExceptionsPerPolicyOptInResponse = z.infer< >; 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 index d864150fd3f23..a2b399dcdfc92 100644 --- 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 @@ -22,6 +22,9 @@ paths: properties: status: type: boolean + reason: + type: string + enum: [newDeployment, userOptedIn] post: summary: Opt-in to endpoint exceptions per policy diff --git a/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/per_policy_opt_in.ts b/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/per_policy_opt_in.ts index 250dd1cbf2e71..7f06574ffa666 100644 --- a/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/per_policy_opt_in.ts +++ b/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/per_policy_opt_in.ts @@ -55,3 +55,17 @@ export const optInForPerPolicyEndpointExceptions = async (kbnClient: KbnClient): }, }); }; + +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 cea8ab205f200..0701409bcabe3 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 @@ -498,6 +498,7 @@ export class EndpointAppContextService { return new ReferenceDataClient( this.savedObjects.createInternalScopedSoClient({ readonly: false }), + this.experimentalFeatures, this.createLogger('ReferenceDataClient') ); } 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 41ba274ea5085..68c7197b8c414 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,6 +5,12 @@ * 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, @@ -12,6 +18,7 @@ import type { ReferenceDataItemKey, ReferenceDataSavedObject, } from './types'; +import { wrapErrorIfNeeded } from '../../utils'; export const REFERENCE_DATA_SAVED_OBJECT_TYPE = 'security:reference-data'; @@ -36,47 +43,90 @@ 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]: - (): ReferenceDataSavedObject => ({ - id: REF_DATA_KEYS.spaceAwarenessResponseActionsMigration, - 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]: - (): ReferenceDataSavedObject => ({ - id: REF_DATA_KEYS.orphanResponseActionsSpace, - owner: 'EDR', - type: 'RESPONSE-ACTIONS', - metadata: { spaceId: '' }, - }), + [REF_DATA_KEYS.orphanResponseActionsSpace]: async (): Promise< + ReferenceDataSavedObject + > => ({ + id: REF_DATA_KEYS.orphanResponseActionsSpace, + owner: 'EDR', + type: 'RESPONSE-ACTIONS', + metadata: { spaceId: '' }, + }), - [REF_DATA_KEYS.endpointExceptionsPerPolicyOptInStatus]: - (): ReferenceDataSavedObject => ({ - id: REF_DATA_KEYS.endpointExceptionsPerPolicyOptInStatus, - owner: 'EDR', - type: 'OPT-IN-STATUS', - metadata: { status: false }, - }), + [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}`, + }); + + // 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 retreive Endpoint exceptions list while determining default per-policy opt-in status.' + ); + } + } + + if (shouldAutomaticallyOptIn) { + return { + id: REF_DATA_KEYS.endpointExceptionsPerPolicyOptInStatus, + owner: 'EDR', + type: 'OPT-IN-STATUS', + metadata: { status: true, reason: 'newDeployment' }, + }; + } 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 index 68d1fcba3f664..c3f658c0b933a 100644 --- 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 @@ -7,15 +7,25 @@ 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, logger); + const referenceDataClient = new ReferenceDataClient(soClient, experimentalFeatures, logger); const optInStatus = await referenceDataClient.get( REF_DATA_KEYS.endpointExceptionsPerPolicyOptInStatus @@ -23,3 +33,29 @@ export const getIsEndpointExceptionsPerPolicyEnabled = async ( 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/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 4566af187925d..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, @@ -24,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 ) {} @@ -71,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 7e7128534ee43..b2cdd754be84b 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 @@ -55,4 +55,5 @@ export interface OrphanResponseActionsMetadata { export interface OptInStatusMetadata { status: boolean; + reason?: 'newDeployment' | 'userOptedIn'; } 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 index ddb4ba4707f52..153877f838e13 100644 --- 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 @@ -32,7 +32,7 @@ export const getOptInToPerPolicyEndpointExceptionsPOSTHandler = ( REF_DATA_KEYS.endpointExceptionsPerPolicyOptInStatus ); - currentOptInStatus.metadata.status = true; + currentOptInStatus.metadata = { status: true, reason: 'userOptedIn' }; await referenceDataClient.update( REF_DATA_KEYS.endpointExceptionsPerPolicyOptInStatus, @@ -61,9 +61,7 @@ export const getOptInToPerPolicyEndpointExceptionsGETHandler = ( REF_DATA_KEYS.endpointExceptionsPerPolicyOptInStatus ); - const body: GetEndpointExceptionsPerPolicyOptInResponse = { - status: currentOptInStatus.metadata.status, - }; + const body: GetEndpointExceptionsPerPolicyOptInResponse = { ...currentOptInStatus.metadata }; return res.ok({ body }); } catch (err) { 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.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts index 6d5b5b8892d2d..a2f6853f4e9c3 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 @@ -236,6 +236,7 @@ export class ManifestManager { if ( await getIsEndpointExceptionsPerPolicyEnabled( this.savedObjectsClientFactory.createInternalScopedSoClient({ readonly: false }), + this.experimentalFeatures, this.logger ) ) { @@ -346,6 +347,7 @@ export class ManifestManager { if ( await getIsEndpointExceptionsPerPolicyEnabled( this.savedObjectsClientFactory.createInternalScopedSoClient({ readonly: false }), + this.experimentalFeatures, this.logger ) ) { 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 8ba30190dcac1..31e8652f43a96 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,10 @@ import type { TrialCompanionRoutesDeps } from './lib/trial_companion/types'; import { setupAlertsCapabilitiesSwitcher } from './lib/capabilities/alerts_capabilities_switcher'; import { securityAlertsProfileInitializer } from './lib/anonymization'; 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'; @@ -731,6 +735,14 @@ 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 + ); + this.ruleMonitoringService.start(core, plugins); const savedObjectsClient = new SavedObjectsClient( diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/workflows/basic_license_essentials_tier/create_endpoint_exceptions.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/workflows/basic_license_essentials_tier/create_endpoint_exceptions.ts index 1f27ebaa580f4..ac1929c3fcade 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/workflows/basic_license_essentials_tier/create_endpoint_exceptions.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/workflows/basic_license_essentials_tier/create_endpoint_exceptions.ts @@ -20,7 +20,7 @@ import { waitForAlertsToBePresent, } from '@kbn/detections-response-ftr-services'; import { - deleteEndpointExceptionsPerPolicyOptInSO, + disablePerPolicyEndpointExceptions, optInForPerPolicyEndpointExceptions, } from '@kbn/security-solution-plugin/scripts/endpoint/common/per_policy_opt_in'; import { createRuleWithExceptionEntries } from '../../../../utils'; @@ -106,7 +106,7 @@ export default ({ getService }: FtrProviderContext) => { 'x-pack/solutions/security/test/fixtures/es_archives/rule_exceptions/agent' ); - await deleteEndpointExceptionsPerPolicyOptInSO(kibanaServer); + await disablePerPolicyEndpointExceptions(kibanaServer); }); beforeEach(async () => { 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 c4e98d5a2af72..6d954e0a889e1 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 @@ -37,7 +37,7 @@ import { ensureSpaceIdExists } from '@kbn/security-solution-plugin/scripts/endpo import { addSpaceIdToPath } from '@kbn/spaces-utils'; import type { ToolingLog } from '@kbn/tooling-log'; import { - deleteEndpointExceptionsPerPolicyOptInSO, + disablePerPolicyEndpointExceptions, optInForPerPolicyEndpointExceptions, } from '@kbn/security-solution-plugin/scripts/endpoint/common/per_policy_opt_in'; import type { CustomRole } from '../../../../config/services/types'; @@ -196,7 +196,7 @@ export default function artifactImportAPIIntegrationTests({ getService }: FtrPro }); after(async () => { - await deleteEndpointExceptionsPerPolicyOptInSO(kbnServer); + await disablePerPolicyEndpointExceptions(kbnServer); }); describe('when checking privileges', () => { @@ -1426,7 +1426,7 @@ export default function artifactImportAPIIntegrationTests({ getService }: FtrPro ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id ); - await deleteEndpointExceptionsPerPolicyOptInSO(kbnServer); + await disablePerPolicyEndpointExceptions(kbnServer); }); beforeEach(async () => { @@ -1476,7 +1476,7 @@ export default function artifactImportAPIIntegrationTests({ getService }: FtrPro } }); - it('should add global artifact tag if it is missing is missing', async () => { + it('should add global artifact tag if it is missing', async () => { await endpointOpsAnalystSupertest .post(`${EXCEPTION_LIST_URL}/_import`) .set('kbn-xsrf', 'true') 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 df2920fdebdd4..c9cb6d0bb499f 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 @@ -23,7 +23,7 @@ import type { ArtifactTestData } from '@kbn/test-suites-xpack-security-endpoint/ 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 { - deleteEndpointExceptionsPerPolicyOptInSO, + disablePerPolicyEndpointExceptions, optInForPerPolicyEndpointExceptions, } from '@kbn/security-solution-plugin/scripts/endpoint/common/per_policy_opt_in'; import type { CustomRole } from '../../../../config/services/types'; @@ -61,7 +61,7 @@ export default function ({ getService }: FtrProviderContext) { beforeEach(async () => { if (IS_ENDPOINT_EXCEPTION_MOVE_FF_ENABLED) { - await deleteEndpointExceptionsPerPolicyOptInSO(kibanaServer); + await disablePerPolicyEndpointExceptions(kibanaServer); } }); 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 index 86c79066fcb8d..2a56af86197c5 100644 --- 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 @@ -10,8 +10,11 @@ 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/per_policy_opt_in'; +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'; @@ -19,6 +22,7 @@ export default function endpointExceptionsPerPolicyOptInTests({ getService }: Ft const utils = getService('securitySolutionUtils'); const config = getService('config'); const kibanaServer = getService('kibanaServer'); + const endpointArtifactTestResources = getService('endpointArtifactTestResources'); const isServerless = config.get('serverless'); const superuserRole = isServerless ? 'admin' : 'elastic'; @@ -37,7 +41,7 @@ export default function endpointExceptionsPerPolicyOptInTests({ getService }: Ft describe('@ess @serverless @skipInServerlessMKI Endpoint Exceptions Per Policy Opt-In API', function () { beforeEach(async () => { - await deleteEndpointExceptionsPerPolicyOptInSO(kibanaServer); + await disablePerPolicyEndpointExceptions(kibanaServer); }); if (IS_ENDPOINT_EXCEPTION_MOVE_FF_ENABLED) { @@ -79,7 +83,7 @@ export default function endpointExceptionsPerPolicyOptInTests({ getService }: Ft const initialOptInStatusSO = await findEndpointExceptionsPerPolicyOptInSO( kibanaServer ); - expect(initialOptInStatusSO).to.be(undefined); + expect(initialOptInStatusSO?.attributes.metadata.status).to.be(false); await superuser .post(ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE) @@ -94,7 +98,7 @@ export default function endpointExceptionsPerPolicyOptInTests({ getService }: Ft const initialOptInStatusSO = await findEndpointExceptionsPerPolicyOptInSO( kibanaServer ); - expect(initialOptInStatusSO).to.be(undefined); + expect(initialOptInStatusSO?.attributes.metadata.status).to.be(false); await superuser .post(ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE) @@ -136,13 +140,35 @@ export default function endpointExceptionsPerPolicyOptInTests({ getService }: Ft }); describe('functionality', () => { - it('should return `false` opt-in status when it has not been set', async () => { + 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 + ); + + 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 + ); + const response = await superuser .get(ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE) .set(HEADERS) .expect(200); - expect(response.body.status).to.be(false); + expect(response.body).to.eql({ status: true, reason: 'newDeployment' }); }); it('should return `true` opt-in status when it has been set', async () => { @@ -156,7 +182,7 @@ export default function endpointExceptionsPerPolicyOptInTests({ getService }: Ft .set(HEADERS) .expect(200); - expect(response.body.status).to.be(true); + expect(response.body).to.eql({ status: true, reason: 'userOptedIn' }); }); }); }); From 9947fb81368e18a76923d5dc6cc8da2fb69f3bae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Fri, 27 Mar 2026 17:48:57 +0100 Subject: [PATCH 24/46] [ui] do not show callouts on new deployments --- .../components/step_about_rule/index.test.tsx | 12 +- .../components/step_about_rule/index.tsx | 4 +- .../pages/rule_creation/index.tsx | 11 +- .../pages/rule_editing/index.tsx | 39 ++-- .../all_exception_items_table/index.test.tsx | 186 +++++++++++++----- .../all_exception_items_table/index.tsx | 68 ++++--- .../exceptions/pages/shared_lists/index.tsx | 9 +- .../pages/shared_lists/shared_lists.test.tsx | 46 ++++- .../use_endpoint_per_policy_opt_in.ts | 18 +- .../hooks/use_per_policy_opt_in.tsx | 5 +- .../endpoint_exceptions_form.test.tsx | 9 +- .../components/endpoint_exceptions_form.tsx | 5 +- .../pages/policy/view/tabs/policy_tabs.tsx | 6 +- .../endpoint/endpoint_app_context_services.ts | 9 +- 14 files changed, 294 insertions(+), 133 deletions(-) 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 eefa052fe269f..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 @@ -135,7 +135,9 @@ describe.skip('StepAboutRuleComponent', () => { })); useSecurityJobsMock = (useSecurityJobs as jest.Mock).mockImplementation(() => ({ jobs: [] })); - mockedUseGetEndpointExceptionsPerPolicyOptIn.mockImplementation(() => ({ data: false })); + mockedUseGetEndpointExceptionsPerPolicyOptIn.mockImplementation(() => ({ + data: { status: false }, + })); }); it('it renders StepRuleDescription if isReadOnlyView is true and "name" property exists', () => { @@ -151,7 +153,9 @@ describe.skip('StepAboutRuleComponent', () => { }); it('shows endpoint exceptions for rule definition if they are not per-policy', async () => { - mockedUseGetEndpointExceptionsPerPolicyOptIn.mockImplementation(() => ({ data: undefined })); + mockedUseGetEndpointExceptionsPerPolicyOptIn.mockImplementation(() => ({ + data: { status: false }, + })); const wrapper = mount( {}} />, { wrappingComponent: TestProviders as EnzymeComponentType<{}>, @@ -166,7 +170,9 @@ describe.skip('StepAboutRuleComponent', () => { }); it('does not show endpoint exceptions for rule definition if they are per-policy', async () => { - mockedUseGetEndpointExceptionsPerPolicyOptIn.mockImplementation(() => ({ data: true })); + 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 87e463b06988a..531304e21f834 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 @@ -103,7 +103,7 @@ const StepAboutRuleComponent: FC = ({ const [indexPattern, setIndexPattern] = useState(indexIndexPattern); - const { data: isEndpointExceptionsPerPolicyEnabled } = useGetEndpointExceptionsPerPolicyOptIn(); + const { data: endpointPerPolicyOptIn } = useGetEndpointExceptionsPerPolicyOptIn(); useEffect(() => { if (index != null && (dataViewId === '' || dataViewId == null)) { @@ -336,7 +336,7 @@ const StepAboutRuleComponent: FC = ({ /> - {!isEndpointExceptionsPerPolicyEnabled ? ( + {endpointPerPolicyOptIn?.status === false ? ( - {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( @@ -577,20 +576,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..754a21cc2bbc3 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,50 @@ 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?.reason !== 'newDeployment') { + return ( + + ); + } + + if (isDetectionRuleWithEndpointExceptions) { + if (endpointPerPolicyOptIn?.status === false) { + return ( + + ); + } else if (endpointPerPolicyOptIn?.reason === 'userOptedIn') { + return ( + + ); + } + } + + return null; + }, [ + isEndpointExceptionsMovedFFEnabled, + isEndpointSecurityRule, + endpointPerPolicyOptIn, + isDetectionRuleWithEndpointExceptions, + ]); return ( <> @@ -549,20 +582,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 ea3cc590d3da0..611e2aa968707 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 @@ -30,6 +30,7 @@ import { useApi, useExceptionLists } from '@kbn/securitysolution-list-hooks'; import { EmptyViewerState, ViewerStatus } from '@kbn/securitysolution-exception-list-components'; 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'; @@ -97,6 +98,7 @@ export const SharedLists = React.memo(() => { const isEndpointExceptionsMovedFFEnabled = useIsExperimentalFeatureEnabled( 'endpointExceptionsMovedUnderManagement' ); + const { data: endpointPerPolicyOptIn } = useGetEndpointExceptionsPerPolicyOptIn(); const canAccessEndpointExceptions = useEndpointExceptionsCapability('showEndpointExceptions'); const canWriteEndpointExceptions = useEndpointExceptionsCapability('crudEndpointExceptions'); @@ -606,9 +608,10 @@ export const SharedLists = React.memo(() => {
- {isEndpointExceptionsMovedFFEnabled && ( - - )} + {isEndpointExceptionsMovedFFEnabled && + endpointPerPolicyOptIn?.reason !== 'newDeployment' && ( + + )} {!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 17dda968af8e7..53f9083ce683d 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'); @@ -67,7 +69,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(); @@ -114,6 +119,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 () => { @@ -245,8 +251,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( @@ -261,6 +289,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/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 index e2fdd379929d9..9a750416c4b2e 100644 --- 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 @@ -4,6 +4,7 @@ * 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 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'; @@ -19,19 +20,20 @@ export const useSendEndpointExceptionsPerPolicyOptIn = () => { }); }; -export const useGetEndpointExceptionsPerPolicyOptIn = () => { +export const useGetEndpointExceptionsPerPolicyOptIn = (): UseQueryResult< + GetEndpointExceptionsPerPolicyOptInResponse, + Error +> => { const http = useHttp(); - return useQuery({ + return useQuery({ queryKey: ['endpointExceptionsPerPolicyOptIn'], queryFn: async () => { try { - return ( - await http.get( - ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE, - { version: '1' } - ) - ).status; + return await http.get( + ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE, + { version: '1' } + ); } catch (error) { return error; } 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 index 2a0a39bda14c8..2460ec9c99bce 100644 --- 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 @@ -34,8 +34,9 @@ export const usePerPolicyOptIn = (): { const [isCalloutDismissed, setIsCalloutDismissed] = useState( sessionStorage.get(STORAGE_KEY) === true ); - const shouldShowCallout = isPerPolicyOptIn !== true && !isCalloutDismissed; - const shouldShowAction = isPerPolicyOptIn !== true && canOptInPerPolicyEndpointExceptions; + const shouldShowCallout = isPerPolicyOptIn?.status === false && !isCalloutDismissed; + const shouldShowAction = + isPerPolicyOptIn?.status === false && canOptInPerPolicyEndpointExceptions; const [isModalVisible, setIsModalVisible] = useState(false); 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 242466c1c1d10..4f2e7ad6df7aa 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 @@ -24,6 +24,7 @@ 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); @@ -160,8 +161,8 @@ describe('Endpoint exceptions form', () => { }, }); mockedUseGetEndpointExceptionsPerPolicyOptIn.mockReturnValue({ - data: true, - } as UseQueryResult); + data: { status: false }, + } as UseQueryResult); formProps = { item: latestUpdatedItem, @@ -520,8 +521,8 @@ describe('Endpoint exceptions form', () => { it('should not display policy assignment when user has not opted in', async () => { mockedUseGetEndpointExceptionsPerPolicyOptIn.mockReturnValue({ - data: false, - } as UseQueryResult); + data: { status: false }, + } as UseQueryResult); await act(() => render()); expect(renderResult.queryByTestId(`${formPrefix}-effectedPolicies`)).not.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 9a833acf3e74a..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 @@ -141,15 +141,14 @@ export const EndpointExceptionsForm: React.FC = mem hasPartialCodeSignatureEntry([exception]) ); - const { data: isEndpointExceptionsPerPolicyOptedIn } = useGetEndpointExceptionsPerPolicyOptIn(); + const { data: isPerPolicyOptIn } = useGetEndpointExceptionsPerPolicyOptIn(); const canAssignArtifactPerPolicy = useCanAssignArtifactPerPolicy( exception, mode, hasFormChanged ); - const showAssignmentSection = - isEndpointExceptionsPerPolicyOptedIn === true && canAssignArtifactPerPolicy; + 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/policy/view/tabs/policy_tabs.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/tabs/policy_tabs.tsx index 65a83d792c17b..87ee71cd49dd8 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/tabs/policy_tabs.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/management/pages/policy/view/tabs/policy_tabs.tsx @@ -247,7 +247,7 @@ export const PolicyTabs = React.memo(() => { [http] ); - const { data: isEndpointExceptionsPerPolicyOptedIn } = useGetEndpointExceptionsPerPolicyOptIn(); + const { data: isPerPolicyOptIn } = useGetEndpointExceptionsPerPolicyOptIn(); const tabs: Record = useMemo(() => { const trustedAppsLabels = { @@ -484,7 +484,7 @@ export const PolicyTabs = React.memo(() => { getArtifactPath={getEndpointExceptionsListPath} getPolicyArtifactsPath={getPolicyEndpointExceptionsPath} canWriteArtifact={canWriteEndpointExceptions} - disableArtifactsByPolicy={!isEndpointExceptionsPerPolicyOptedIn} + disableArtifactsByPolicy={!isPerPolicyOptIn?.status} /> ), @@ -536,7 +536,7 @@ export const PolicyTabs = React.memo(() => { canReadEndpointExceptions, getEndpointExceptionsApiClientInstance, canWriteEndpointExceptions, - isEndpointExceptionsPerPolicyOptedIn, + isPerPolicyOptIn?.status, isEnterprise, ]); 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 0701409bcabe3..5b36559454c7f 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 @@ -518,12 +518,11 @@ export class EndpointAppContextService { const referenceDataClient = this.getReferenceDataClient(); - const endpointExceptionsMovedUnderManagement = - await referenceDataClient.get( - REF_DATA_KEYS.endpointExceptionsPerPolicyOptInStatus - ); + const optInStatusMetadata = await referenceDataClient.get( + REF_DATA_KEYS.endpointExceptionsPerPolicyOptInStatus + ); - return endpointExceptionsMovedUnderManagement.metadata.status; + return optInStatusMetadata.metadata.status; } public getServerConfigValue( From c5bd1f8829ce79f663e144dd282aaf2f8868075c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Fri, 27 Mar 2026 17:55:04 +0100 Subject: [PATCH 25/46] [ui] show error to user when opt-in status cannot be fetched --- .../use_endpoint_per_policy_opt_in.ts | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) 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 index 9a750416c4b2e..1e53903ca2aa5 100644 --- 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 @@ -6,9 +6,10 @@ */ import type { UseQueryResult } from '@kbn/react-query'; import { useMutation, useQuery } from '@kbn/react-query'; +import { i18n } from '@kbn/i18n'; 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'; +import { useHttp, useToasts } from '../../../common/lib/kibana'; export const useSendEndpointExceptionsPerPolicyOptIn = () => { const http = useHttp(); @@ -25,18 +26,24 @@ export const useGetEndpointExceptionsPerPolicyOptIn = (): UseQueryResult< Error > => { const http = useHttp(); + const toasts = useToasts(); return useQuery({ queryKey: ['endpointExceptionsPerPolicyOptIn'], - queryFn: async () => { - try { - return await http.get( - ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE, - { version: '1' } - ); - } catch (error) { - return error; - } + queryFn: async () => + http.get( + ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE, + { version: '1' } + ), + onError: (error) => { + toasts.addError(error, { + title: i18n.translate( + 'xpack.securitySolution.endpointExceptionsPerPolicyOptIn.errorToastTitle', + { + defaultMessage: 'Error fetching endpoint exceptions per policy opt-in status', + } + ), + }); }, }); }; From fe9997a7feb3e7681bfd2265a9005d37ec32a3e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Fri, 27 Mar 2026 18:44:55 +0100 Subject: [PATCH 26/46] [license] gate opt-in below platinum license --- .../hooks/use_per_policy_opt_in.tsx | 10 +++++-- .../view/endpoint_exceptions.test.tsx | 28 +++++++++++++++++++ .../endpoint_exceptions_per_policy_opt_in.ts | 10 +++++-- 3 files changed, 43 insertions(+), 5 deletions(-) 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 index 2460ec9c99bce..7318687cacd23 100644 --- 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 @@ -26,7 +26,8 @@ export const usePerPolicyOptIn = (): { } => { const { sessionStorage } = useKibana().services; const toasts = useToasts(); - const { canOptInPerPolicyEndpointExceptions } = useUserPrivileges().endpointPrivileges; + const { canOptInPerPolicyEndpointExceptions, canCreateArtifactsByPolicy } = + useUserPrivileges().endpointPrivileges; const { mutate, isLoading } = useSendEndpointExceptionsPerPolicyOptIn(); const { data: isPerPolicyOptIn, refetch } = useGetEndpointExceptionsPerPolicyOptIn(); @@ -34,9 +35,12 @@ export const usePerPolicyOptIn = (): { const [isCalloutDismissed, setIsCalloutDismissed] = useState( sessionStorage.get(STORAGE_KEY) === true ); - const shouldShowCallout = isPerPolicyOptIn?.status === false && !isCalloutDismissed; + const shouldShowCallout = + canCreateArtifactsByPolicy && isPerPolicyOptIn?.status === false && !isCalloutDismissed; const shouldShowAction = - isPerPolicyOptIn?.status === false && canOptInPerPolicyEndpointExceptions; + canCreateArtifactsByPolicy && + isPerPolicyOptIn?.status === false && + canOptInPerPolicyEndpointExceptions; const [isModalVisible, setIsModalVisible] = useState(false); 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 8f7f2286e0d6e..b68bce64cc8e1 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 @@ -156,6 +156,19 @@ describe('When on the endpoint exceptions page', () => { 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(); @@ -205,6 +218,21 @@ describe('When on the endpoint exceptions page', () => { 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(); 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 index 153877f838e13..b8c706c93e2e0 100644 --- 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 @@ -89,10 +89,16 @@ export const registerEndpointExceptionsPerPolicyOptInRoute = ( version: '1', validate: {}, }, - // todo: would be better to use `withEndpointAuthz` with `canOptInPerPolicyEndpointExceptions`, + // todo: would be better to add `canOptInPerPolicyEndpointExceptions` 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. - getOptInToPerPolicyEndpointExceptionsPOSTHandler(endpointContext.service) + withEndpointAuthz( + // hiding behind Platinum+ license + { all: ['canCreateArtifactsByPolicy'] }, + logger, + getOptInToPerPolicyEndpointExceptionsPOSTHandler(endpointContext.service) + ) ); router.versioned From af8240e19bea757d958a981ec45b2e2d8a69cd90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Fri, 27 Mar 2026 19:38:28 +0100 Subject: [PATCH 27/46] fix floating promise lint issue --- .../security/plugins/security_solution/server/plugin.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 31e8652f43a96..4164fff238f67 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/plugin.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/plugin.ts @@ -741,7 +741,11 @@ export class Plugin implements ISecuritySolutionPlugin { ), config.experimentalFeatures, logger - ); + ).catch((error) => { + this.logger.error( + `Error initializing Endpoint Exceptions per-policy opt-in status: ${error}` + ); + }); this.ruleMonitoringService.start(core, plugins); From f1e9dd66f9a21648666d655a042ec52051058cec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Mon, 30 Mar 2026 09:21:59 +0200 Subject: [PATCH 28/46] [test] fix: no opt-in in basic license test --- .../create_endpoint_exceptions.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/workflows/basic_license_essentials_tier/create_endpoint_exceptions.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/workflows/basic_license_essentials_tier/create_endpoint_exceptions.ts index ac1929c3fcade..6c53077a0c489 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/workflows/basic_license_essentials_tier/create_endpoint_exceptions.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/exceptions/workflows/basic_license_essentials_tier/create_endpoint_exceptions.ts @@ -19,10 +19,6 @@ import { waitForRuleSuccess, waitForAlertsToBePresent, } from '@kbn/detections-response-ftr-services'; -import { - disablePerPolicyEndpointExceptions, - optInForPerPolicyEndpointExceptions, -} from '@kbn/security-solution-plugin/scripts/endpoint/common/per_policy_opt_in'; import { createRuleWithExceptionEntries } from '../../../../utils'; import { createListsIndex, @@ -84,7 +80,6 @@ export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); const log = getService('log'); const es = getService('es'); - const kibanaServer = getService('kibanaServer'); describe('@serverless @serverlessQA @ess @skipInServerlessMKI create_endpoint_exceptions', () => { before(async () => { @@ -94,8 +89,6 @@ export default ({ getService }: FtrProviderContext) => { await esArchiver.load( 'x-pack/solutions/security/test/fixtures/es_archives/rule_exceptions/agent' ); - - await optInForPerPolicyEndpointExceptions(kibanaServer); }); after(async () => { @@ -105,8 +98,6 @@ export default ({ getService }: FtrProviderContext) => { await esArchiver.unload( 'x-pack/solutions/security/test/fixtures/es_archives/rule_exceptions/agent' ); - - await disablePerPolicyEndpointExceptions(kibanaServer); }); beforeEach(async () => { From fb4c09d5249a4138c6e63342848dcc931c1402a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Mon, 30 Mar 2026 09:27:47 +0200 Subject: [PATCH 29/46] [test] fix jest tests --- .../view/components/endpoint_exceptions_form.test.tsx | 4 ++-- .../view/components/per_policy_opt_in_callout.test.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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 4f2e7ad6df7aa..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 @@ -161,7 +161,7 @@ describe('Endpoint exceptions form', () => { }, }); mockedUseGetEndpointExceptionsPerPolicyOptIn.mockReturnValue({ - data: { status: false }, + data: { status: true }, } as UseQueryResult); formProps = { @@ -535,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/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 index 24ee4f1b3fc0f..1966bf321be84 100644 --- 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 @@ -56,11 +56,11 @@ describe('EndpointExceptionsPerPolicyOptInCallout', () => { render(); const noPermissionMessage = renderResult.getByText( - 'Contact your administrator to update details.' + /Contact your administrator to update details./ ); expect(noPermissionMessage).toBeInTheDocument(); - const updateDetailsButton = renderResult.getByTestId( + const updateDetailsButton = renderResult.queryByTestId( 'updateDetailsEndpointExceptionsPerPolicyOptInButton' ); expect(updateDetailsButton).not.toBeInTheDocument(); From 1a801870d7eea5dee068e58b5b0fe6c6c77fe9d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Mon, 30 Mar 2026 12:54:22 +0200 Subject: [PATCH 30/46] [test] fix endpoint exceptions cypress test --- .../trial_license_complete_tier/endpoint_exceptions.ts | 6 ++++++ 1 file changed, 6 insertions(+) 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 c9cb6d0bb499f..bf3d09d11d407 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 @@ -275,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')]; @@ -331,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 = [ @@ -353,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 From bfc3c4439aa10f91354aa47799089a2204b02d00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Mon, 30 Mar 2026 13:43:02 +0200 Subject: [PATCH 31/46] [docs] hide api docs until FF is enabled --- .../endpoint_exceptions_per_policy_opt_in.schema.yaml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 index a2b399dcdfc92..d6b9580b8d80d 100644 --- 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 @@ -8,7 +8,9 @@ paths: summary: Retrieve endpoint exceptions per policy opt-in operationId: GetEndpointExceptionsPerPolicyOptIn x-codegen-enabled: true - x-labels: [ess, serverless] + 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': @@ -30,7 +32,9 @@ paths: summary: Opt-in to endpoint exceptions per policy operationId: PerformEndpointExceptionsPerPolicyOptIn x-codegen-enabled: true - x-labels: [ess, serverless] + 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': From c09b717cf95c5b5dcec47dfa4dcdba9df99a0c25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Mon, 30 Mar 2026 15:08:55 +0200 Subject: [PATCH 32/46] [rbac] fix `admin` vs `superuser` for `serverless` vs `ess` --- .../endpoint/service/authz/authz.test.ts | 82 ++++++++++--------- .../common/endpoint/service/authz/authz.ts | 5 +- .../endpoint/use_endpoint_privileges.test.ts | 4 +- .../endpoint/use_endpoint_privileges.ts | 9 +- .../public/management/links.ts | 4 +- .../endpoint/endpoint_app_context_services.ts | 3 +- 6 files changed, 60 insertions(+), 47 deletions(-) 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 222ab1fb07721..54f963d059b30 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 @@ -322,19 +325,20 @@ 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'} - ${'canOptInPerPolicyEndpointExceptions'} | ${true} | ${['superuser', 'role-2']} | ${'user has superuser role'} - ${'canOptInPerPolicyEndpointExceptions'} | ${true} | ${['admin', 'role-2']} | ${'user has admin role'} - ${'canReadAdminData'} | ${false} | ${['role-2']} | ${'user does NOT have superuser role'} - ${'canWriteAdminData'} | ${false} | ${['role-2']} | ${'user does NOT superuser role'} - ${'canOptInPerPolicyEndpointExceptions'} | ${false} | ${['role-2']} | ${'user does NOT have superuser role'} + isServerless | privilege | expectedResult | roles | description + ${false} | ${'canReadAdminData'} | ${true} | ${['superuser', 'role-2']} | ${'user has superuser role'} + ${false} | ${'canWriteAdminData'} | ${true} | ${['superuser', 'role-2']} | ${'user has superuser role'} + ${false} | ${'canOptInPerPolicyEndpointExceptions'} | ${true} | ${['superuser', 'role-2']} | ${'user has superuser role on ESS'} + ${true} | ${'canOptInPerPolicyEndpointExceptions'} | ${true} | ${['admin', 'role-2']} | ${'user has admin role on Serverless'} + ${false} | ${'canReadAdminData'} | ${false} | ${['role-2']} | ${'user does NOT have superuser role'} + ${false} | ${'canWriteAdminData'} | ${false} | ${['role-2']} | ${'user does NOT superuser role'} + ${false} | ${'canOptInPerPolicyEndpointExceptions'} | ${false} | ${['admin', 'role-2']} | ${'user does NOT have superuser role on ESS'} + ${true} | ${'canOptInPerPolicyEndpointExceptions'} | ${false} | ${['superuser', 'role-2']} | ${'user does NOT have admin role on Serverless'} `( 'should set `$privilege` to `$expectedResult` when $description', - ({ privilege, expectedResult, roles }) => { + ({ privilege, expectedResult, roles, isServerless }) => { expect( - calculateEndpointAuthz(licenseService, fleetAuthz, roles)[ + calculateEndpointAuthz(licenseService, fleetAuthz, roles, isServerless)[ privilege as keyof EndpointAuthz ] ).toEqual(expectedResult); 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 5fc890a2e3b43..4e3555e1a661d 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,12 +63,15 @@ 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(); const hasEndpointManagementAccess = hasSuperuserRole; @@ -103,7 +106,7 @@ export const calculateEndpointAuthz = ( const canReadEndpointExceptions = hasAuth('showEndpointExceptions'); const canWriteEndpointExceptions = hasAuth('crudEndpointExceptions'); - const canOptInPerPolicyEndpointExceptions = hasSuperuserRole || hasAdminRole; + const canOptInPerPolicyEndpointExceptions = hasSuperuserPrivileges; const canManageGlobalArtifacts = hasAuth('writeGlobalArtifacts'); 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/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/server/endpoint/endpoint_app_context_services.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts index 5b36559454c7f..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 @@ -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 ); } From f1b4c5d95b4d99e61436839862d6064fe9be02db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Mon, 30 Mar 2026 15:14:09 +0200 Subject: [PATCH 33/46] [ui] fix condition for showing callout --- .../components/all_exception_items_table/index.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 754a21cc2bbc3..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 @@ -513,7 +513,10 @@ const ExceptionsViewerComponent = ({ return null; } - if (isEndpointSecurityRule && endpointPerPolicyOptIn?.reason !== 'newDeployment') { + if ( + isEndpointSecurityRule && + (endpointPerPolicyOptIn?.status === false || endpointPerPolicyOptIn?.reason === 'userOptedIn') + ) { return ( Date: Mon, 30 Mar 2026 15:18:39 +0200 Subject: [PATCH 34/46] [test] move new utilities in existing `endpoint_artifact_services.ts` --- .../common/endpoint_artifact_services.ts | 63 ++++++++++++++++ .../endpoint/common/per_policy_opt_in.ts | 71 ------------------- .../artifact_import.ts | 2 +- .../endpoint_exceptions.ts | 2 +- .../endpoint_exceptions_per_policy_opt_in.ts | 2 +- 5 files changed, 66 insertions(+), 74 deletions(-) delete mode 100644 x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/per_policy_opt_in.ts 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/scripts/endpoint/common/per_policy_opt_in.ts b/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/per_policy_opt_in.ts deleted file mode 100644 index 7f06574ffa666..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/per_policy_opt_in.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * 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 { KbnClient } from '@kbn/kbn-client'; -import type { SavedObjectsFindResult } from '@kbn/core/server'; -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 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/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 6d954e0a889e1..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 @@ -39,7 +39,7 @@ import type { ToolingLog } from '@kbn/tooling-log'; import { disablePerPolicyEndpointExceptions, optInForPerPolicyEndpointExceptions, -} from '@kbn/security-solution-plugin/scripts/endpoint/common/per_policy_opt_in'; +} 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'; 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 bf3d09d11d407..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 @@ -25,7 +25,7 @@ import { getHunter } from '@kbn/security-solution-plugin/scripts/endpoint/common import { disablePerPolicyEndpointExceptions, optInForPerPolicyEndpointExceptions, -} from '@kbn/security-solution-plugin/scripts/endpoint/common/per_policy_opt_in'; +} 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'; 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 index 2a56af86197c5..ca77f4ebdd39d 100644 --- 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 @@ -12,7 +12,7 @@ import { deleteEndpointExceptionsPerPolicyOptInSO, disablePerPolicyEndpointExceptions, findEndpointExceptionsPerPolicyOptInSO, -} from '@kbn/security-solution-plugin/scripts/endpoint/common/per_policy_opt_in'; +} 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'; From 681aadce887b41ff6b856f5e0ac466bbd3dec1ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Mon, 30 Mar 2026 15:56:18 +0200 Subject: [PATCH 35/46] [data] store username and timestamp on opt-in --- .../endpoint/lib/reference_data/constants.ts | 7 ++++++- .../server/endpoint/lib/reference_data/types.ts | 2 ++ .../endpoint_exceptions_per_policy_opt_in.ts | 14 ++++++++++++-- .../endpoint_exceptions_per_policy_opt_in.ts | 11 ++++++++++- 4 files changed, 30 insertions(+), 4 deletions(-) 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 68c7197b8c414..23234f904b6e5 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 @@ -118,7 +118,12 @@ export const REF_DATA_KEY_INITIAL_VALUE: Readonly< id: REF_DATA_KEYS.endpointExceptionsPerPolicyOptInStatus, owner: 'EDR', type: 'OPT-IN-STATUS', - metadata: { status: true, reason: 'newDeployment' }, + metadata: { + status: true, + reason: 'newDeployment', + user: 'automatic-opt-in', + timestamp: new Date().toISOString(), + }, }; } else { return { 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 b2cdd754be84b..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 @@ -56,4 +56,6 @@ export interface OrphanResponseActionsMetadata { export interface OptInStatusMetadata { status: boolean; reason?: 'newDeployment' | 'userOptedIn'; + user?: string; + timestamp?: string; } 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 index b8c706c93e2e0..9e3407f1ffaaa 100644 --- 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 @@ -26,13 +26,20 @@ export const getOptInToPerPolicyEndpointExceptionsPOSTHandler = ( 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' }; + currentOptInStatus.metadata = { + status: true, + reason: 'userOptedIn', + user: user?.username ?? 'unknown', + timestamp: new Date().toISOString(), + }; await referenceDataClient.update( REF_DATA_KEYS.endpointExceptionsPerPolicyOptInStatus, @@ -61,7 +68,10 @@ export const getOptInToPerPolicyEndpointExceptionsGETHandler = ( REF_DATA_KEYS.endpointExceptionsPerPolicyOptInStatus ); - const body: GetEndpointExceptionsPerPolicyOptInResponse = { ...currentOptInStatus.metadata }; + const body: GetEndpointExceptionsPerPolicyOptInResponse = { + status: currentOptInStatus.metadata.status, + reason: currentOptInStatus.metadata.reason, + }; return res.ok({ body }); } catch (err) { 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 index ca77f4ebdd39d..6e49f29bee9a5 100644 --- 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 @@ -25,6 +25,7 @@ export default function endpointExceptionsPerPolicyOptInTests({ getService }: Ft 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 = ( @@ -79,7 +80,7 @@ export default function endpointExceptionsPerPolicyOptInTests({ getService }: Ft }); describe('functionality', () => { - it('should store the opt-in status in reference data', async () => { + it('should store the opt-in status, reason, user, and timestamp in reference data', async () => { const initialOptInStatusSO = await findEndpointExceptionsPerPolicyOptInSO( kibanaServer ); @@ -92,6 +93,14 @@ export default function endpointExceptionsPerPolicyOptInTests({ getService }: Ft 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 () => { From 3e40722f98f8b9a5a00ee12745c8237817d2dd52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Mon, 30 Mar 2026 16:07:58 +0200 Subject: [PATCH 36/46] [test] fix for serverless --- .../endpoint_exceptions_per_policy_opt_in.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 index 6e49f29bee9a5..2835342ce6d60 100644 --- 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 @@ -153,7 +153,8 @@ export default function endpointExceptionsPerPolicyOptInTests({ getService }: Ft // 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 + ENDPOINT_EXCEPTIONS_LIST_DEFINITION, + { supertest: superuser } ); const response = await superuser @@ -169,7 +170,8 @@ export default function endpointExceptionsPerPolicyOptInTests({ getService }: Ft 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 + ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id, + superuser ); const response = await superuser From 4feb866dcbf585b7b0ba8f4d7cbcb558564de03c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Mon, 30 Mar 2026 17:11:06 +0200 Subject: [PATCH 37/46] [task] optimize SO service usage --- .../manifest_manager/manifest_manager.test.ts | 5 ++ .../manifest_manager/manifest_manager.ts | 53 ++++++++++++------- 2 files changed, 38 insertions(+), 20 deletions(-) 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 44b6175b6aaf4..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 @@ -140,6 +140,7 @@ describe('ManifestManager', () => { }); beforeEach(() => { + jest.clearAllMocks(); mockedGetIsEndpointExceptionsPerPolicyEnabled.mockResolvedValue(false); }); @@ -1299,6 +1300,8 @@ describe('ManifestManager', () => { defaultFeatures ), }); + + expect(mockedGetIsEndpointExceptionsPerPolicyEnabled).toHaveBeenCalledTimes(1); }); }); @@ -1342,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 a2f6853f4e9c3..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 @@ -195,6 +195,7 @@ export class ManifestManager { policyId, schemaVersion, exceptionItemDecorator, + isEndpointExceptionsPerPolicyEnabled, }: { elClient: ExceptionListClient; listId: ArtifactListId; @@ -202,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[] = []; @@ -233,13 +235,7 @@ export class ManifestManager { let exceptions: ExceptionListItemSchema[]; - if ( - await getIsEndpointExceptionsPerPolicyEnabled( - this.savedObjectsClientFactory.createInternalScopedSoClient({ readonly: false }), - this.experimentalFeatures, - this.logger - ) - ) { + 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 { @@ -263,9 +259,11 @@ export class ManifestManager { os, policyId, exceptionItemDecorator, + isEndpointExceptionsPerPolicyEnabled, }: { os: string; policyId?: string; + isEndpointExceptionsPerPolicyEnabled?: boolean; } & BuildArtifactsForOsOptions): Promise { return buildArtifact( await this.getCachedExceptions({ @@ -275,6 +273,7 @@ export class ManifestManager { policyId, listId, exceptionItemDecorator, + isEndpointExceptionsPerPolicyEnabled, }), this.schemaVersion, os, @@ -290,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, + }) ); } @@ -312,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[] = []; @@ -339,22 +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 ( - await getIsEndpointExceptionsPerPolicyEnabled( - this.savedObjectsClientFactory.createInternalScopedSoClient({ readonly: false }), - this.experimentalFeatures, - this.logger - ) - ) { + if (isEndpointExceptionsPerPolicyEnabled) { policySpecificArtifacts = await this.buildArtifactsByPolicy( allPolicyIds, ArtifactConstants.SUPPORTED_ENDPOINT_EXCEPTIONS_OPERATING_SYSTEMS, - buildArtifactsForOsOptions + buildArtifactsForOsOptions, + isEndpointExceptionsPerPolicyEnabled ); } else { allPolicyIds.forEach((policyId) => { @@ -709,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), From 6c597904e01603844e326f8ffa0d1519668e9a83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Mon, 30 Mar 2026 17:11:50 +0200 Subject: [PATCH 38/46] [ui] typo change --- .../server/endpoint/lib/reference_data/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 23234f904b6e5..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 @@ -108,7 +108,7 @@ export const REF_DATA_KEY_INITIAL_VALUE: Readonly< } catch (error) { throw wrapErrorIfNeeded( error, - 'Failed to retreive Endpoint exceptions list while determining default per-policy opt-in status.' + 'Failed to retrieve Endpoint exceptions list while determining default per-policy opt-in status.' ); } } From adf803fdb55edc6bcd9b30d0b32b944bfcb3eaef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Mon, 30 Mar 2026 17:14:13 +0200 Subject: [PATCH 39/46] [chore] update imports --- .../components/artifact_list_page/artifact_list_page.tsx | 2 +- .../pages/endpoint_exceptions/hooks/use_per_policy_opt_in.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 1ee3d94eb9cd3..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,7 +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/src/header_menu'; +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'; 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 index 7318687cacd23..6ec7f5fd4cd5f 100644 --- 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 @@ -7,7 +7,7 @@ import React, { useCallback, useState } from 'react'; import { i18n } from '@kbn/i18n'; -import type { Action } from '@kbn/securitysolution-exception-list-components/src/header_menu'; +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'; From 53185e45b5ecb447f4ca06c53e155843870a7d99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Mon, 30 Mar 2026 17:54:13 +0200 Subject: [PATCH 40/46] [ui] do not call opt-in API with FF disabled --- .../components/step_about_rule/index.tsx | 6 +-- .../exceptions/pages/shared_lists/index.tsx | 3 +- .../use_endpoint_per_policy_opt_in.test.ts | 51 +++++++++++++++++++ .../use_endpoint_per_policy_opt_in.ts | 5 ++ 4 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 x-pack/solutions/security/plugins/security_solution/public/management/hooks/artifacts/use_endpoint_per_policy_opt_in.test.ts 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 531304e21f834..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 @@ -336,7 +336,9 @@ const StepAboutRuleComponent: FC = ({ /> - {endpointPerPolicyOptIn?.status === false ? ( + {endpointPerPolicyOptIn?.status === true ? ( + + ) : ( = ({ }} /> - ) : ( - )} {
{isEndpointExceptionsMovedFFEnabled && - endpointPerPolicyOptIn?.reason !== 'newDeployment' && ( + (endpointPerPolicyOptIn?.status === false || + endpointPerPolicyOptIn?.reason === 'userOptedIn') && ( )} 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 index 1e53903ca2aa5..ef7b2e6983045 100644 --- 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 @@ -7,6 +7,7 @@ import type { UseQueryResult } from '@kbn/react-query'; import { useMutation, useQuery } from '@kbn/react-query'; import { i18n } from '@kbn/i18n'; +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, useToasts } from '../../../common/lib/kibana'; @@ -27,9 +28,13 @@ export const useGetEndpointExceptionsPerPolicyOptIn = (): UseQueryResult< > => { const http = useHttp(); const toasts = useToasts(); + const isEndpointExceptionsMovedUnderManagementFFEnabled = useIsExperimentalFeatureEnabled( + 'endpointExceptionsMovedUnderManagement' + ); return useQuery({ queryKey: ['endpointExceptionsPerPolicyOptIn'], + enabled: isEndpointExceptionsMovedUnderManagementFFEnabled, queryFn: async () => http.get( ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE, From 95f0db6a10b93266f3d8be56c2579c4b3cba3c94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Mon, 30 Mar 2026 17:55:00 +0200 Subject: [PATCH 41/46] [ui] add doc link --- .../shared/kbn-doc-links/src/get_doc_links.ts | 1 + .../shared/kbn-doc-links/src/types.ts | 1 + .../components/per_policy_opt_in_callout.tsx | 19 ++++++++++++------- 3 files changed, 14 insertions(+), 7 deletions(-) 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/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 index deea8e8b91b9f..8d336927ffc4e 100644 --- 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 @@ -5,10 +5,11 @@ * 2.0. */ -import { EuiButton, EuiCallOut, EuiFlexGroup, EuiSpacer } from '@elastic/eui'; +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; @@ -18,6 +19,8 @@ export interface EndpointExceptionsPerPolicyOptInCalloutProps { export const EndpointExceptionsPerPolicyOptInCallout: React.FC = memo(({ onDismiss, onClickUpdateDetails, canOptIn }) => { + const { docLinks } = useKibana().services; + return ( - {i18n.translate( - 'xpack.securitySolution.endpointExceptions.perPolicyOptInCalloutLearnMore', - { - defaultMessage: 'Learn more', // TODO: Update with actual link to docs once available - } - )} + + {i18n.translate( + 'xpack.securitySolution.endpointExceptions.perPolicyOptInCalloutLearnMore', + { + defaultMessage: 'Learn more', + } + )} + ) : ( From b2212413f99527d3031c5cc9f5da2ca8d7d00884 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Mon, 30 Mar 2026 17:57:00 +0200 Subject: [PATCH 42/46] type fix --- .../artifacts/manifest_manager/manifest_manager.mock.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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(); } From 098956264ebe044f0faeb4ffa9ad9ecd3ef744b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Mon, 30 Mar 2026 18:27:12 +0200 Subject: [PATCH 43/46] [ui] remove toast from api request hook --- .../artifacts/use_endpoint_per_policy_opt_in.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) 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 index ef7b2e6983045..87ea7bc6d684e 100644 --- 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 @@ -6,11 +6,10 @@ */ import type { UseQueryResult } from '@kbn/react-query'; import { useMutation, useQuery } from '@kbn/react-query'; -import { i18n } from '@kbn/i18n'; 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, useToasts } from '../../../common/lib/kibana'; +import { useHttp } from '../../../common/lib/kibana'; export const useSendEndpointExceptionsPerPolicyOptIn = () => { const http = useHttp(); @@ -27,7 +26,6 @@ export const useGetEndpointExceptionsPerPolicyOptIn = (): UseQueryResult< Error > => { const http = useHttp(); - const toasts = useToasts(); const isEndpointExceptionsMovedUnderManagementFFEnabled = useIsExperimentalFeatureEnabled( 'endpointExceptionsMovedUnderManagement' ); @@ -40,15 +38,5 @@ export const useGetEndpointExceptionsPerPolicyOptIn = (): UseQueryResult< ENDPOINT_EXCEPTIONS_PER_POLICY_OPT_IN_ROUTE, { version: '1' } ), - onError: (error) => { - toasts.addError(error, { - title: i18n.translate( - 'xpack.securitySolution.endpointExceptionsPerPolicyOptIn.errorToastTitle', - { - defaultMessage: 'Error fetching endpoint exceptions per policy opt-in status', - } - ), - }); - }, }); }; From dd8cf08b9fa17d23851a21021b9612f1af8eca97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Tue, 31 Mar 2026 11:41:10 +0200 Subject: [PATCH 44/46] [test] update endpoint list API tests --- .../endpoint_list_api_rbac.ts | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) 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(); From 37a2094d24afc5b522419dfef7e19f85deae1a02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Tue, 31 Mar 2026 12:04:12 +0200 Subject: [PATCH 45/46] [auth] fix `canReadAdminData` and `canWriteAdminData` --- .../endpoint/service/authz/authz.test.ts | 37 ++++++++++--------- .../common/endpoint/service/authz/authz.ts | 6 +-- 2 files changed, 22 insertions(+), 21 deletions(-) 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 54f963d059b30..b9d023ea0045c 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 @@ -324,24 +324,25 @@ describe('Endpoint Authz service', () => { } ); - it.each` - isServerless | privilege | expectedResult | roles | description - ${false} | ${'canReadAdminData'} | ${true} | ${['superuser', 'role-2']} | ${'user has superuser role'} - ${false} | ${'canWriteAdminData'} | ${true} | ${['superuser', 'role-2']} | ${'user has superuser role'} - ${false} | ${'canOptInPerPolicyEndpointExceptions'} | ${true} | ${['superuser', 'role-2']} | ${'user has superuser role on ESS'} - ${true} | ${'canOptInPerPolicyEndpointExceptions'} | ${true} | ${['admin', 'role-2']} | ${'user has admin role on Serverless'} - ${false} | ${'canReadAdminData'} | ${false} | ${['role-2']} | ${'user does NOT have superuser role'} - ${false} | ${'canWriteAdminData'} | ${false} | ${['role-2']} | ${'user does NOT superuser role'} - ${false} | ${'canOptInPerPolicyEndpointExceptions'} | ${false} | ${['admin', 'role-2']} | ${'user does NOT have superuser role on ESS'} - ${true} | ${'canOptInPerPolicyEndpointExceptions'} | ${false} | ${['superuser', 'role-2']} | ${'user does NOT have admin role on Serverless'} - `( - 'should set `$privilege` to `$expectedResult` when $description', - ({ privilege, expectedResult, roles, isServerless }) => { - expect( - calculateEndpointAuthz(licenseService, fleetAuthz, roles, isServerless)[ - privilege as keyof EndpointAuthz - ] - ).toEqual(expectedResult); + describe.each(['canReadAdminData', 'canWriteAdminData', 'canOptInPerPolicyEndpointExceptions'])( + '%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); + } + ); } ); }); 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 4e3555e1a661d..26423fc5f3d6f 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 @@ -116,9 +116,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, From 0c64ee57acd84ae3036a77821fb9fac026053911 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Tue, 31 Mar 2026 12:10:23 +0200 Subject: [PATCH 46/46] [auth] simplification: reuse `hasAdminWrite`, get rid of `canOptInPerPolicyEndpointExceptions` --- .../endpoint/service/authz/authz.test.ts | 40 +++++++++---------- .../common/endpoint/service/authz/authz.ts | 3 -- .../common/endpoint/types/authz.ts | 2 - .../hooks/use_per_policy_opt_in.tsx | 9 ++--- .../view/endpoint_exceptions.test.tsx | 14 +++---- .../endpoint_exceptions_per_policy_opt_in.ts | 2 +- 6 files changed, 29 insertions(+), 41 deletions(-) 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 b9d023ea0045c..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 @@ -324,27 +324,24 @@ describe('Endpoint Authz service', () => { } ); - describe.each(['canReadAdminData', 'canWriteAdminData', 'canOptInPerPolicyEndpointExceptions'])( - '%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.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()', () => { @@ -393,7 +390,6 @@ describe('Endpoint Authz service', () => { canReadEventFilters: false, canReadEndpointExceptions: false, canWriteEndpointExceptions: false, - canOptInPerPolicyEndpointExceptions: false, canReadAdminData: false, canWriteAdminData: false, canReadScriptsLibrary: false, 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 26423fc5f3d6f..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 @@ -106,7 +106,6 @@ export const calculateEndpointAuthz = ( const canReadEndpointExceptions = hasAuth('showEndpointExceptions'); const canWriteEndpointExceptions = hasAuth('crudEndpointExceptions'); - const canOptInPerPolicyEndpointExceptions = hasSuperuserPrivileges; const canManageGlobalArtifacts = hasAuth('writeGlobalArtifacts'); @@ -185,7 +184,6 @@ export const calculateEndpointAuthz = ( canReadEventFilters, canReadEndpointExceptions, canWriteEndpointExceptions, - canOptInPerPolicyEndpointExceptions, canManageGlobalArtifacts, // --------------------------------------------------------- @@ -261,7 +259,6 @@ export const getEndpointAuthzInitialState = (): EndpointAuthz => { canReadEventFilters: false, canReadEndpointExceptions: false, canWriteEndpointExceptions: false, - canOptInPerPolicyEndpointExceptions: false, canManageGlobalArtifacts: false, canReadWorkflowInsights: false, canWriteWorkflowInsights: false, 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 17424b032e737..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,8 +99,6 @@ export interface EndpointAuthz { canReadEndpointExceptions: boolean; /** if the user has read permissions for endpoint exceptions */ canWriteEndpointExceptions: boolean; - /** if the user has permissions to opt-in to per-policy endpoint exceptions */ - canOptInPerPolicyEndpointExceptions: 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/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 index 6ec7f5fd4cd5f..dbd985e7af83d 100644 --- 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 @@ -26,8 +26,7 @@ export const usePerPolicyOptIn = (): { } => { const { sessionStorage } = useKibana().services; const toasts = useToasts(); - const { canOptInPerPolicyEndpointExceptions, canCreateArtifactsByPolicy } = - useUserPrivileges().endpointPrivileges; + const { canWriteAdminData, canCreateArtifactsByPolicy } = useUserPrivileges().endpointPrivileges; const { mutate, isLoading } = useSendEndpointExceptionsPerPolicyOptIn(); const { data: isPerPolicyOptIn, refetch } = useGetEndpointExceptionsPerPolicyOptIn(); @@ -38,9 +37,7 @@ export const usePerPolicyOptIn = (): { const shouldShowCallout = canCreateArtifactsByPolicy && isPerPolicyOptIn?.status === false && !isCalloutDismissed; const shouldShowAction = - canCreateArtifactsByPolicy && - isPerPolicyOptIn?.status === false && - canOptInPerPolicyEndpointExceptions; + canCreateArtifactsByPolicy && isPerPolicyOptIn?.status === false && canWriteAdminData; const [isModalVisible, setIsModalVisible] = useState(false); @@ -91,7 +88,7 @@ export const usePerPolicyOptIn = (): { ) : null, 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 b68bce64cc8e1..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 @@ -59,7 +59,7 @@ describe('When on the endpoint exceptions page', () => { mockUserPrivileges.mockReturnValue({ endpointPrivileges: getEndpointAuthzInitialStateMock({ canWriteEndpointExceptions: true, - canOptInPerPolicyEndpointExceptions: false, + canWriteAdminData: false, }), }); }); @@ -80,7 +80,7 @@ describe('When on the endpoint exceptions page', () => { mockUserPrivileges.mockReturnValue({ endpointPrivileges: getEndpointAuthzInitialStateMock({ canWriteEndpointExceptions: false, - canOptInPerPolicyEndpointExceptions: false, + canWriteAdminData: false, }), }); }); @@ -130,7 +130,7 @@ describe('When on the endpoint exceptions page', () => { mockUserPrivileges.mockReturnValue({ endpointPrivileges: getEndpointAuthzInitialStateMock({ canWriteEndpointExceptions: true, - canOptInPerPolicyEndpointExceptions: true, + canWriteAdminData: true, }), }); }); @@ -323,12 +323,12 @@ describe('When on the endpoint exceptions page', () => { }); describe('RBAC', () => { - describe('when user has the `canOptInPerPolicyEndpointExceptions` privilege', () => { + describe('when user has the `canWriteAdminData` privilege', () => { beforeEach(() => { mockUserPrivileges.mockReturnValue({ endpointPrivileges: getEndpointAuthzInitialStateMock({ canWriteEndpointExceptions: true, - canOptInPerPolicyEndpointExceptions: true, + canWriteAdminData: true, }), }); }); @@ -355,12 +355,12 @@ describe('When on the endpoint exceptions page', () => { }); }); - describe('when user does not have the `canOptInPerPolicyEndpointExceptions` privilege', () => { + describe('when user does not have the `canWriteAdminData` privilege', () => { beforeEach(() => { mockUserPrivileges.mockReturnValue({ endpointPrivileges: getEndpointAuthzInitialStateMock({ canWriteEndpointExceptions: true, - canOptInPerPolicyEndpointExceptions: false, + canWriteAdminData: false, }), }); }); 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 index 9e3407f1ffaaa..e106dd010936e 100644 --- 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 @@ -99,7 +99,7 @@ export const registerEndpointExceptionsPerPolicyOptInRoute = ( version: '1', validate: {}, }, - // todo: would be better to add `canOptInPerPolicyEndpointExceptions` to `withEndpointAuthz`, instead of + // 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.