Skip to content

Commit 20d0fb2

Browse files
authored
[EDR Workflows][Device Control] Artifact creation license check (#233334)
Extends trusted devices artifacts to require enterprise licensing in addition to existing feature flag and PLI controls. When users downgrade from enterprise licenses, trusted devices will serve empty arrays instead of actual configurations. Changes: - Added dual licensing check: PLI (serverless) AND enterprise license (ESS) - Maintains existing feature flag requirement (experimentalFeatures.trustedDevices) - Follows same pattern as host isolation exceptions
1 parent 45c335e commit 20d0fb2

File tree

4 files changed

+237
-15
lines changed

4 files changed

+237
-15
lines changed

x-pack/solutions/security/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.mock.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { parseExperimentalConfigValue } from '../../../../../common/experimental
2828
import { createProductFeaturesServiceMock } from '../../../../lib/product_features_service/mocks';
2929
import type { ProductFeaturesService } from '../../../../lib/product_features_service/product_features_service';
3030
import { createSavedObjectsClientFactoryMock } from '../../saved_objects/saved_objects_client_factory.mocks';
31+
import { createLicenseServiceMock } from '../../../../../common/license/mocks';
3132

3233
export const createExceptionListResponse = (data: ExceptionListItemSchema[], total?: number) => ({
3334
data,
@@ -109,6 +110,7 @@ export const buildManifestManagerContextMock = (
109110
.features,
110111
packagerTaskPackagePolicyUpdateBatchSize: 10,
111112
esClient: elasticsearchServiceMock.createElasticsearchClient(),
113+
licenseService: createLicenseServiceMock(),
112114
};
113115
};
114116

x-pack/solutions/security/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts

Lines changed: 194 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import { ManifestManager } from './manifest_manager';
3838
import type { EndpointArtifactClientInterface } from '../artifact_client';
3939
import { EndpointError } from '../../../../../common/endpoint/errors';
4040
import type { Artifact } from '@kbn/fleet-plugin/server';
41-
import { ProductFeatureSecurityKey } from '@kbn/security-solution-features/keys';
41+
import { ProductFeatureKey } from '@kbn/security-solution-features/keys';
4242
import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types/src/response/exception_list_item_schema';
4343
import {
4444
createFetchAllArtifactsIterableMock,
@@ -47,6 +47,7 @@ import {
4747
import type { ExperimentalFeatures } from '../../../../../common';
4848
import { allowedExperimentalValues } from '../../../../../common';
4949
import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server';
50+
import { createLicenseServiceMock } from '../../../../../common/license/mocks';
5051

5152
const getArtifactObject = (artifact: InternalArtifactSchema) =>
5253
JSON.parse(Buffer.from(artifact.body!, 'base64').toString());
@@ -863,7 +864,7 @@ describe('ManifestManager', () => {
863864
tags: ['policy:all'],
864865
});
865866
const context = buildManifestManagerContextMock({}, [
866-
ProductFeatureSecurityKey.endpointArtifactManagement,
867+
ProductFeatureKey.endpointArtifactManagement,
867868
]);
868869
const manifestManager = new ManifestManager(context);
869870

@@ -943,8 +944,8 @@ describe('ManifestManager', () => {
943944
tags: ['policy:all'],
944945
});
945946
const context = buildManifestManagerContextMock({}, [
946-
ProductFeatureSecurityKey.endpointArtifactManagement,
947-
ProductFeatureSecurityKey.endpointHostIsolationExceptions,
947+
ProductFeatureKey.endpointArtifactManagement,
948+
ProductFeatureKey.endpointHostIsolationExceptions,
948949
]);
949950
const manifestManager = new ManifestManager(context);
950951

@@ -1129,6 +1130,15 @@ describe('ManifestManager', () => {
11291130
const context = buildManifestManagerContextMock({
11301131
experimentalFeatures: ['trustedDevices'],
11311132
});
1133+
// Set up licensing to allow trusted devices (both PLI and enterprise)
1134+
context.productFeaturesService.isEnabled = jest.fn().mockImplementation((key) => {
1135+
return (
1136+
key === ProductFeatureKey.endpointTrustedDevices ||
1137+
key === ProductFeatureKey.endpointArtifactManagement
1138+
);
1139+
});
1140+
context.licenseService = createLicenseServiceMock();
1141+
context.licenseService.isEnterprise = jest.fn().mockReturnValue(true);
11321142
const manifestManager = new ManifestManager(context);
11331143

11341144
context.exceptionListClient.findExceptionListItem = mockFindExceptionListItemResponses({});
@@ -1224,6 +1234,14 @@ describe('ManifestManager', () => {
12241234
const context = buildManifestManagerContextMock({
12251235
experimentalFeatures: ['trustedDevices'],
12261236
});
1237+
context.productFeaturesService.isEnabled = jest.fn().mockImplementation((key) => {
1238+
return (
1239+
key === ProductFeatureKey.endpointTrustedDevices ||
1240+
key === ProductFeatureKey.endpointArtifactManagement
1241+
);
1242+
});
1243+
context.licenseService = createLicenseServiceMock();
1244+
context.licenseService.isEnterprise = jest.fn().mockReturnValue(true);
12271245
const manifestManager = new ManifestManager(context);
12281246

12291247
context.exceptionListClient.findExceptionListItem = mockFindExceptionListItemResponses({
@@ -2428,4 +2446,176 @@ describe('ManifestManager', () => {
24282446
});
24292447
});
24302448
});
2449+
2450+
describe('shouldRetrieveExceptions for trusted devices licensing logic', () => {
2451+
let manifestManager: ManifestManager;
2452+
let context: ManifestManagerContext;
2453+
2454+
beforeEach(() => {
2455+
context = buildManifestManagerContextMock({});
2456+
});
2457+
2458+
interface ManifestManagerWithPrivateMethods {
2459+
shouldRetrieveExceptions: (listId: string) => boolean;
2460+
}
2461+
2462+
describe('when trustedDevices feature flag is disabled', () => {
2463+
beforeEach(() => {
2464+
context = buildManifestManagerContextMock({
2465+
experimentalFeatures: [], // No trustedDevices feature
2466+
});
2467+
context.licenseService = createLicenseServiceMock();
2468+
manifestManager = new ManifestManager(context);
2469+
});
2470+
2471+
test('should return false for trusted devices artifacts when feature flag is disabled', () => {
2472+
const shouldRetrieve = (
2473+
manifestManager as unknown as ManifestManagerWithPrivateMethods
2474+
).shouldRetrieveExceptions(ENDPOINT_ARTIFACT_LISTS.trustedDevices.id);
2475+
expect(shouldRetrieve).toBe(false);
2476+
});
2477+
2478+
test('should return true for other artifact types regardless of feature flag', () => {
2479+
const shouldRetrieveExceptions = (
2480+
manifestManager as unknown as ManifestManagerWithPrivateMethods
2481+
).shouldRetrieveExceptions(ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id);
2482+
const shouldRetrieveTrustedApps = (
2483+
manifestManager as unknown as ManifestManagerWithPrivateMethods
2484+
).shouldRetrieveExceptions(ENDPOINT_ARTIFACT_LISTS.trustedApps.id);
2485+
2486+
expect(shouldRetrieveExceptions).toBe(true);
2487+
expect(shouldRetrieveTrustedApps).toBe(true);
2488+
});
2489+
});
2490+
2491+
describe('when trustedDevices feature flag is enabled', () => {
2492+
beforeEach(() => {
2493+
context = buildManifestManagerContextMock({
2494+
experimentalFeatures: ['trustedDevices'],
2495+
});
2496+
context.licenseService = createLicenseServiceMock();
2497+
});
2498+
2499+
test('should return false when only PLI is enabled (enterprise required)', () => {
2500+
context.productFeaturesService.isEnabled = jest.fn().mockImplementation((key) => {
2501+
return key === ProductFeatureKey.endpointTrustedDevices;
2502+
});
2503+
context.licenseService.isEnterprise = jest.fn().mockReturnValue(false);
2504+
manifestManager = new ManifestManager(context);
2505+
2506+
const shouldRetrieve = (
2507+
manifestManager as unknown as ManifestManagerWithPrivateMethods
2508+
).shouldRetrieveExceptions(ENDPOINT_ARTIFACT_LISTS.trustedDevices.id);
2509+
2510+
expect(shouldRetrieve).toBe(false);
2511+
expect(context.productFeaturesService.isEnabled).toHaveBeenCalledWith(
2512+
ProductFeatureKey.endpointTrustedDevices
2513+
);
2514+
});
2515+
2516+
test('should return false when only enterprise license is present (PLI required)', () => {
2517+
context.productFeaturesService.isEnabled = jest.fn().mockReturnValue(false);
2518+
context.licenseService.isEnterprise = jest.fn().mockReturnValue(true);
2519+
manifestManager = new ManifestManager(context);
2520+
2521+
const shouldRetrieve = (
2522+
manifestManager as unknown as ManifestManagerWithPrivateMethods
2523+
).shouldRetrieveExceptions(ENDPOINT_ARTIFACT_LISTS.trustedDevices.id);
2524+
2525+
expect(shouldRetrieve).toBe(false);
2526+
});
2527+
2528+
test('should return true when both PLI and enterprise license are enabled', () => {
2529+
context.productFeaturesService.isEnabled = jest.fn().mockImplementation((key) => {
2530+
return key === ProductFeatureKey.endpointTrustedDevices;
2531+
});
2532+
context.licenseService.isEnterprise = jest.fn().mockReturnValue(true);
2533+
manifestManager = new ManifestManager(context);
2534+
2535+
const shouldRetrieve = (
2536+
manifestManager as unknown as ManifestManagerWithPrivateMethods
2537+
).shouldRetrieveExceptions(ENDPOINT_ARTIFACT_LISTS.trustedDevices.id);
2538+
2539+
expect(shouldRetrieve).toBe(true);
2540+
expect(context.productFeaturesService.isEnabled).toHaveBeenCalledWith(
2541+
ProductFeatureKey.endpointTrustedDevices
2542+
);
2543+
expect(context.licenseService.isEnterprise).toHaveBeenCalled();
2544+
});
2545+
2546+
test('should return false when neither PLI nor enterprise license are enabled', () => {
2547+
context.productFeaturesService.isEnabled = jest.fn().mockReturnValue(false);
2548+
context.licenseService.isEnterprise = jest.fn().mockReturnValue(false);
2549+
manifestManager = new ManifestManager(context);
2550+
2551+
const shouldRetrieve = (
2552+
manifestManager as unknown as ManifestManagerWithPrivateMethods
2553+
).shouldRetrieveExceptions(ENDPOINT_ARTIFACT_LISTS.trustedDevices.id);
2554+
2555+
expect(shouldRetrieve).toBe(false);
2556+
expect(context.productFeaturesService.isEnabled).toHaveBeenCalledWith(
2557+
ProductFeatureKey.endpointTrustedDevices
2558+
);
2559+
});
2560+
});
2561+
2562+
describe('host isolation exceptions licensing logic', () => {
2563+
beforeEach(() => {
2564+
context = buildManifestManagerContextMock({});
2565+
context.licenseService = createLicenseServiceMock();
2566+
});
2567+
2568+
test('should return true for host isolation exceptions when product feature is enabled', () => {
2569+
context.productFeaturesService.isEnabled = jest.fn().mockImplementation((key) => {
2570+
return key === ProductFeatureKey.endpointHostIsolationExceptions;
2571+
});
2572+
manifestManager = new ManifestManager(context);
2573+
2574+
const shouldRetrieve = (
2575+
manifestManager as unknown as ManifestManagerWithPrivateMethods
2576+
).shouldRetrieveExceptions(ENDPOINT_ARTIFACT_LISTS.hostIsolationExceptions.id);
2577+
2578+
expect(shouldRetrieve).toBe(true);
2579+
expect(context.productFeaturesService.isEnabled).toHaveBeenCalledWith(
2580+
ProductFeatureKey.endpointHostIsolationExceptions
2581+
);
2582+
});
2583+
2584+
test('should return false for host isolation exceptions when product feature is disabled', () => {
2585+
context.productFeaturesService.isEnabled = jest.fn().mockReturnValue(false);
2586+
manifestManager = new ManifestManager(context);
2587+
2588+
const shouldRetrieve = (
2589+
manifestManager as unknown as ManifestManagerWithPrivateMethods
2590+
).shouldRetrieveExceptions(ENDPOINT_ARTIFACT_LISTS.hostIsolationExceptions.id);
2591+
2592+
expect(shouldRetrieve).toBe(false);
2593+
expect(context.productFeaturesService.isEnabled).toHaveBeenCalledWith(
2594+
ProductFeatureKey.endpointHostIsolationExceptions
2595+
);
2596+
});
2597+
});
2598+
2599+
describe('default behavior for non-licensed artifacts', () => {
2600+
beforeEach(() => {
2601+
context = buildManifestManagerContextMock({});
2602+
context.licenseService = createLicenseServiceMock();
2603+
manifestManager = new ManifestManager(context);
2604+
});
2605+
2606+
const nonLicensedArtifacts = [
2607+
{ name: 'exceptions', id: ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id },
2608+
{ name: 'trusted apps', id: ENDPOINT_ARTIFACT_LISTS.trustedApps.id },
2609+
{ name: 'event filters', id: ENDPOINT_ARTIFACT_LISTS.eventFilters.id },
2610+
{ name: 'blocklists', id: ENDPOINT_ARTIFACT_LISTS.blocklists.id },
2611+
];
2612+
2613+
test.each(nonLicensedArtifacts)('should return true for $name artifacts', ({ id }) => {
2614+
const shouldRetrieve = (
2615+
manifestManager as unknown as ManifestManagerWithPrivateMethods
2616+
).shouldRetrieveExceptions(id);
2617+
expect(shouldRetrieve).toBe(true);
2618+
});
2619+
});
2620+
});
24312621
});

x-pack/solutions/security/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { stringify } from '../../../utils/stringify';
2828
import { QueueProcessor } from '../../../utils/queue_processor';
2929
import type { ProductFeaturesService } from '../../../../lib/product_features_service/product_features_service';
3030
import type { ExperimentalFeatures } from '../../../../../common';
31+
import type { LicenseService } from '../../../../../common/license';
3132
import type { ManifestSchemaVersion } from '../../../../../common/endpoint/schema/common';
3233
import {
3334
manifestDispatchSchema,
@@ -97,6 +98,7 @@ export interface ManifestManagerContext {
9798
packagerTaskPackagePolicyUpdateBatchSize: number;
9899
esClient: ElasticsearchClient;
99100
productFeaturesService: ProductFeaturesService;
101+
licenseService: LicenseService;
100102
}
101103

102104
const getArtifactIds = (manifest: ManifestSchema) =>
@@ -119,6 +121,7 @@ export class ManifestManager {
119121
protected packagerTaskPackagePolicyUpdateBatchSize: number;
120122
protected esClient: ElasticsearchClient;
121123
protected productFeaturesService: ProductFeaturesService;
124+
protected licenseService: LicenseService;
122125
protected savedObjectsClientFactory: SavedObjectsClientFactory;
123126

124127
constructor(context: ManifestManagerContext) {
@@ -136,6 +139,7 @@ export class ManifestManager {
136139
context.packagerTaskPackagePolicyUpdateBatchSize;
137140
this.esClient = context.esClient;
138141
this.productFeaturesService = context.productFeaturesService;
142+
this.licenseService = context.licenseService;
139143
}
140144

141145
/**
@@ -147,6 +151,39 @@ export class ManifestManager {
147151
return new ManifestClient(this.savedObjectsClient, this.schemaVersion);
148152
}
149153

154+
/**
155+
* Determines if exceptions should be retrieved based on licensing conditions
156+
* @private
157+
*/
158+
private shouldRetrieveExceptions(listId: ArtifactListId): boolean {
159+
// endpointHostIsolationExceptions includes full CRUD support for Host Isolation Exceptions
160+
// Host Isolation Exceptions require feature enablement (serverless).
161+
const isHostIsolationWithFeatureEnabled =
162+
listId === ENDPOINT_ARTIFACT_LISTS.hostIsolationExceptions.id &&
163+
this.productFeaturesService.isEnabled(ProductFeatureKey.endpointHostIsolationExceptions);
164+
165+
// Trusted Devices requires enterprise license (ess) or feature enablement (serverless).
166+
// In serverless .isEnterprise() will always yield true, in ESS feature check .isEnabled() will also always yield true.
167+
// Therefore both conditions must be met in both environments.
168+
const isTrustedDevicesWithFeatureAndEnterpriseLicense =
169+
listId === ENDPOINT_ARTIFACT_LISTS.trustedDevices.id &&
170+
this.experimentalFeatures.trustedDevices &&
171+
this.productFeaturesService.isEnabled(ProductFeatureKey.endpointTrustedDevices) &&
172+
this.licenseService.isEnterprise();
173+
174+
// endpointArtifactManagement includes full CRUD support for all other exception lists + RD support for Host Isolation Exceptions
175+
const isOtherArtifactWithFeatureEnabled =
176+
listId !== ENDPOINT_ARTIFACT_LISTS.hostIsolationExceptions.id &&
177+
listId !== ENDPOINT_ARTIFACT_LISTS.trustedDevices.id &&
178+
this.productFeaturesService.isEnabled(ProductFeatureKey.endpointArtifactManagement);
179+
180+
return (
181+
isHostIsolationWithFeatureEnabled ||
182+
isTrustedDevicesWithFeatureAndEnterpriseLicense ||
183+
isOtherArtifactWithFeatureEnabled
184+
);
185+
}
186+
150187
/**
151188
* Search or get exceptions from the cached map by listId and OS and filter those by policyId/global
152189
*/
@@ -167,17 +204,9 @@ export class ManifestManager {
167204
}): Promise<WrappedTranslatedExceptionList> {
168205
if (!this.cachedExceptionsListsByOs.has(`${listId}-${os}`)) {
169206
let itemsByListId: ExceptionListItemSchema[] = [];
170-
// endpointHostIsolationExceptions includes full CRUD support for Host Isolation Exceptions
171-
// endpointArtifactManagement includes full CRUD support for all other exception lists + RD support for Host Isolation Exceptions
172-
// If there are host isolation exceptions in place but there is a downgrade scenario, those shouldn't be taken into account when generating artifacts.
173-
if (
174-
(listId === ENDPOINT_ARTIFACT_LISTS.hostIsolationExceptions.id &&
175-
this.productFeaturesService.isEnabled(
176-
ProductFeatureKey.endpointHostIsolationExceptions
177-
)) ||
178-
(listId !== ENDPOINT_ARTIFACT_LISTS.hostIsolationExceptions.id &&
179-
this.productFeaturesService.isEnabled(ProductFeatureKey.endpointArtifactManagement))
180-
) {
207+
// If there are host isolation exceptions in place but there is a downgrade scenario (serverless), those shouldn't be taken into account when generating artifacts.
208+
// If there are trusted devices in place but there is a downgrade scenario (ess/serverless), those shouldn't be taken into account when generating artifacts.
209+
if (this.shouldRetrieveExceptions(listId)) {
181210
itemsByListId = await getAllItemsFromEndpointExceptionList({
182211
elClient,
183212
os,

x-pack/solutions/security/plugins/security_solution/server/plugin.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,7 @@ export class Plugin implements ISecuritySolutionPlugin {
657657
packagerTaskPackagePolicyUpdateBatchSize: config.packagerTaskPackagePolicyUpdateBatchSize,
658658
esClient: core.elasticsearch.client.asInternalUser,
659659
productFeaturesService,
660+
licenseService,
660661
});
661662

662663
this.endpointAppContextService.start({

0 commit comments

Comments
 (0)