[Security Solution] add endpoint policy billable flag (#186404)

## Summary

Adds a `meta.billable` flag to the endpoint policy. This flag is used to
make sure we don't bill policy configurations that aren't intended to be
billed in serverless (such as data collection only).


### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios


### For maintainers

- [x] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
This commit is contained in:
Joey F. Poon 2024-06-21 16:16:04 -07:00 committed by GitHub
parent e969602ec5
commit 4f799b835f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 214 additions and 19 deletions

View file

@ -8,6 +8,8 @@
import type { PolicyConfig } from '../types';
import { ProtectionModes, AntivirusRegistrationModes } from '../types';
import { isBillablePolicy } from './policy_config_helpers';
/**
* Return a new default `PolicyConfig` for platinum and above licenses
*/
@ -19,7 +21,7 @@ export const policyFactory = (
clusterName = '',
serverless = false
): PolicyConfig => {
return {
const policy: PolicyConfig = {
meta: {
license,
license_uuid: licenseUid,
@ -174,6 +176,9 @@ export const policyFactory = (
},
},
};
policy.meta.billable = isBillablePolicy(policy);
return policy;
};
/**

View file

@ -12,6 +12,8 @@ import {
disableProtections,
isPolicySetToEventCollectionOnly,
ensureOnlyEventCollectionIsAllowed,
isBillablePolicy,
getPolicyProtectionsReference,
} from './policy_config_helpers';
import { set } from 'lodash';
@ -192,6 +194,35 @@ describe('Policy Config helpers', () => {
}
);
});
describe('isBillablePolicy', () => {
it('doesnt bill if serverless false', () => {
const policy = policyFactory();
const isBillable = isBillablePolicy(policy);
expect(policy.meta.serverless).toBe(false);
expect(isBillable).toBe(false);
});
it('doesnt bill if event collection only', () => {
const policy = ensureOnlyEventCollectionIsAllowed(policyFactory());
policy.meta.serverless = true;
const isBillable = isBillablePolicy(policy);
expect(isBillable).toBe(false);
});
it.each(getPolicyProtectionsReference())(
'correctly bills if $keyPath is enabled',
(feature) => {
for (const os of feature.osList) {
const policy = ensureOnlyEventCollectionIsAllowed(policyFactory());
policy.meta.serverless = true;
set(policy, `${os}.${feature.keyPath}`, feature.enableValue);
const isBillable = isBillablePolicy(policy);
expect(isBillable).toBe(true);
}
}
);
});
});
// This constant makes sure that if the type `PolicyConfig` is ever modified,
@ -205,6 +236,7 @@ export const eventsOnlyPolicy = (): PolicyConfig => ({
cluster_name: '',
cluster_uuid: '',
serverless: false,
billable: false,
},
windows: {
events: {

View file

@ -22,7 +22,7 @@ const allOsValues = [
PolicyOperatingSystem.windows,
];
const getPolicyProtectionsReference = (): PolicyProtectionReference[] => [
export const getPolicyProtectionsReference = (): PolicyProtectionReference[] => [
{
keyPath: 'malware.mode',
osList: [...allOsValues],
@ -199,3 +199,9 @@ export const isPolicySetToEventCollectionOnly = (
message,
};
};
export function isBillablePolicy(policy: PolicyConfig) {
if (!policy.meta.serverless) return false;
return !isPolicySetToEventCollectionOnly(policy).isOnlyCollectingEvents;
}

View file

@ -949,6 +949,7 @@ export interface PolicyConfig {
cluster_uuid: string;
cluster_name: string;
serverless: boolean;
billable?: boolean;
heartbeatinterval?: number;
};
global_manifest_version: 'latest' | string;

View file

@ -279,6 +279,7 @@ describe('policy details: ', () => {
cluster_name: '',
cluster_uuid: '',
serverless: false,
billable: false,
},
windows: {
events: {

View file

@ -67,6 +67,7 @@ import type {
import { createMockPolicyData } from '../endpoint/services/feature_usage/mocks';
import { ALL_ENDPOINT_ARTIFACT_LIST_IDS } from '../../common/endpoint/service/artifacts/constants';
import { ENDPOINT_EVENT_FILTERS_LIST_ID } from '@kbn/securitysolution-list-constants';
import * as PolicyConfigHelpers from '../../common/endpoint/models/policy_config_helpers';
import { disableProtections } from '../../common/endpoint/models/policy_config_helpers';
import type { ProductFeaturesService } from '../lib/product_features_service/product_features_service';
import { createProductFeaturesServiceMock } from '../lib/product_features_service/mocks';
@ -322,6 +323,24 @@ describe('ingest_integration tests ', () => {
expect(manifestManager.pushArtifacts).not.toHaveBeenCalled();
expect(manifestManager.commit).not.toHaveBeenCalled();
});
it('should correctly set meta.billable', async () => {
const isBillablePolicySpy = jest.spyOn(PolicyConfigHelpers, 'isBillablePolicy');
isBillablePolicySpy.mockReturnValue(false);
const manifestManager = buildManifestManagerMock();
let packagePolicy = await invokeCallback(manifestManager);
expect(isBillablePolicySpy).toHaveBeenCalled();
expect(packagePolicy.inputs[0].config!.policy.value.meta.billable).toBe(false);
isBillablePolicySpy.mockReset();
isBillablePolicySpy.mockReturnValue(true);
packagePolicy = await invokeCallback(manifestManager);
expect(isBillablePolicySpy).toHaveBeenCalled();
expect(packagePolicy.inputs[0].config!.policy.value.meta.billable).toBe(true);
isBillablePolicySpy.mockRestore();
});
});
describe('package policy post create callback', () => {
@ -844,6 +863,7 @@ describe('ingest_integration tests ', () => {
mockPolicy.meta.cluster_uuid = 'updated-uuid';
mockPolicy.meta.license_uuid = 'updated-uid';
mockPolicy.meta.serverless = false;
mockPolicy.meta.billable = false;
const logger = loggingSystemMock.create().get('ingest_integration.test');
const callback = getPackagePolicyUpdateCallback(
logger,
@ -863,6 +883,7 @@ describe('ingest_integration tests ', () => {
policyConfig.inputs[0]!.config!.policy.value.meta.cluster_uuid = 'original-uuid';
policyConfig.inputs[0]!.config!.policy.value.meta.license_uuid = 'original-uid';
policyConfig.inputs[0]!.config!.policy.value.meta.serverless = true;
policyConfig.inputs[0]!.config!.policy.value.meta.billable = true;
const updatedPolicyConfig = await callback(
policyConfig,
soClient,
@ -881,6 +902,7 @@ describe('ingest_integration tests ', () => {
mockPolicy.meta.cluster_uuid = 'updated-uuid';
mockPolicy.meta.license_uuid = 'updated-uid';
mockPolicy.meta.serverless = false;
mockPolicy.meta.billable = false;
const logger = loggingSystemMock.create().get('ingest_integration.test');
const callback = getPackagePolicyUpdateCallback(
logger,
@ -899,6 +921,7 @@ describe('ingest_integration tests ', () => {
policyConfig.inputs[0]!.config!.policy.value.meta.cluster_uuid = 'updated-uuid';
policyConfig.inputs[0]!.config!.policy.value.meta.license_uuid = 'updated-uid';
policyConfig.inputs[0]!.config!.policy.value.meta.serverless = false;
policyConfig.inputs[0]!.config!.policy.value.meta.billable = false;
const updatedPolicyConfig = await callback(
policyConfig,
soClient,
@ -1015,6 +1038,51 @@ describe('ingest_integration tests ', () => {
expect(antivirusRegistrationIn(updatedPolicyConfig)).toBe(false);
});
});
it('should correctly set meta.billable', async () => {
const isBillablePolicySpy = jest.spyOn(PolicyConfigHelpers, 'isBillablePolicy');
const soClient = savedObjectsClientMock.create();
const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
const logger = loggingSystemMock.create().get('ingest_integration.test');
licenseEmitter.next(Enterprise);
const callback = getPackagePolicyUpdateCallback(
logger,
licenseService,
endpointAppContextMock.featureUsageService,
endpointAppContextMock.endpointMetadataService,
cloudService,
esClient,
productFeaturesService
);
const policyConfig = generator.generatePolicyPackagePolicy();
isBillablePolicySpy.mockReturnValue(false);
let updatedPolicyConfig = await callback(
policyConfig,
soClient,
esClient,
requestContextMock.convertContext(ctx),
req
);
expect(isBillablePolicySpy).toHaveBeenCalled();
expect(updatedPolicyConfig.inputs[0]!.config!.policy.value.meta.billable).toEqual(false);
isBillablePolicySpy.mockReset();
isBillablePolicySpy.mockReturnValue(true);
updatedPolicyConfig = await callback(
policyConfig,
soClient,
esClient,
requestContextMock.convertContext(ctx),
req
);
expect(isBillablePolicySpy).toHaveBeenCalled();
expect(updatedPolicyConfig.inputs[0]!.config!.policy.value.meta.billable).toEqual(true);
isBillablePolicySpy.mockRestore();
});
});
describe('package policy delete callback', () => {

View file

@ -35,6 +35,7 @@ import { validateEndpointPackagePolicy } from './handlers/validate_endpoint_pack
import {
isPolicySetToEventCollectionOnly,
ensureOnlyEventCollectionIsAllowed,
isBillablePolicy,
} from '../../common/endpoint/models/policy_config_helpers';
import type { NewPolicyData, PolicyConfig } from '../../common/endpoint/types';
import type { LicenseService } from '../../common/license';
@ -272,6 +273,8 @@ export const getPackagePolicyUpdateCallback = (
updateAntivirusRegistrationEnabled(newEndpointPackagePolicy);
newEndpointPackagePolicy.meta.billable = isBillablePolicy(newEndpointPackagePolicy);
return endpointIntegrationData;
};
};

View file

@ -14,6 +14,7 @@ import { createDefaultPolicy } from './create_default_policy';
import { ProtectionModes } from '../../../common/endpoint/types';
import type { PolicyConfig } from '../../../common/endpoint/types';
import { policyFactory } from '../../../common/endpoint/models/policy_config';
import * as PolicyConfigHelpers from '../../../common/endpoint/models/policy_config_helpers';
import { elasticsearchServiceMock } from '@kbn/core/server/mocks';
import type {
AnyPolicyCreateConfig,
@ -238,6 +239,21 @@ describe('Create Default Policy tests ', () => {
},
});
});
it('should set meta.billable', async () => {
const isBillablePolicySpy = jest.spyOn(PolicyConfigHelpers, 'isBillablePolicy');
const config = createEndpointConfig({ preset: 'DataCollection' });
isBillablePolicySpy.mockReturnValue(false);
let policy = await createDefaultPolicyCallback(config);
expect(policy.meta.billable).toBe(false);
isBillablePolicySpy.mockReturnValue(true);
policy = await createDefaultPolicyCallback(config);
expect(policy.meta.billable).toBe(true);
isBillablePolicySpy.mockRestore();
});
});
describe('When cloud config is set', () => {

View file

@ -24,6 +24,7 @@ import {
import {
disableProtections,
ensureOnlyEventCollectionIsAllowed,
isBillablePolicy,
} from '../../../common/endpoint/models/policy_config_helpers';
import type { ProductFeaturesService } from '../../lib/product_features_service/product_features_service';
@ -61,6 +62,8 @@ export const createDefaultPolicy = (
defaultPolicyPerType = ensureOnlyEventCollectionIsAllowed(defaultPolicyPerType);
}
defaultPolicyPerType.meta.billable = isBillablePolicy(defaultPolicyPerType);
return defaultPolicyPerType;
};

View file

@ -6,4 +6,4 @@
*/
export { endpointMeteringService } from './metering_service';
export { setEndpointPackagePolicyServerlessFlag } from './set_package_policy_flag';
export { setEndpointPackagePolicyServerlessBillingFlags } from './set_package_policy_flag';

View file

@ -19,10 +19,11 @@ import type { PackagePolicy } from '@kbn/fleet-plugin/common';
import type { PackagePolicyClient } from '@kbn/fleet-plugin/server';
import { createPackagePolicyServiceMock } from '@kbn/fleet-plugin/server/mocks';
import { policyFactory } from '@kbn/security-solution-plugin/common/endpoint/models/policy_config';
import { ensureOnlyEventCollectionIsAllowed } from '@kbn/security-solution-plugin/common/endpoint/models/policy_config_helpers';
import { setEndpointPackagePolicyServerlessFlag } from './set_package_policy_flag';
import { setEndpointPackagePolicyServerlessBillingFlags } from './set_package_policy_flag';
describe('setEndpointPackagePolicyServerlessFlag', () => {
describe('setEndpointPackagePolicyServerlessBillingFlags', () => {
let esClientMock: ElasticsearchClientMock;
let soClientMock: jest.Mocked<SavedObjectsClientContract>;
let packagePolicyServiceMock: jest.Mocked<PackagePolicyClient>;
@ -59,7 +60,7 @@ describe('setEndpointPackagePolicyServerlessFlag', () => {
});
packagePolicyServiceMock.bulkCreate.mockImplementation();
await setEndpointPackagePolicyServerlessFlag(
await setEndpointPackagePolicyServerlessBillingFlags(
soClientMock,
esClientMock,
packagePolicyServiceMock
@ -67,8 +68,10 @@ describe('setEndpointPackagePolicyServerlessFlag', () => {
const expectedPolicy1 = cloneDeep(packagePolicy1);
expectedPolicy1!.inputs[0]!.config!.policy.value.meta.serverless = true;
expectedPolicy1!.inputs[0]!.config!.policy.value.meta.billable = true;
const expectedPolicy2 = cloneDeep(packagePolicy2);
expectedPolicy2!.inputs[0]!.config!.policy.value.meta.serverless = true;
expectedPolicy2!.inputs[0]!.config!.policy.value.meta.billable = true;
const expectedPolicies = [expectedPolicy1, expectedPolicy2];
expect(packagePolicyServiceMock.list).toBeCalledWith(soClientMock, {
page: 1,
@ -82,7 +85,7 @@ describe('setEndpointPackagePolicyServerlessFlag', () => {
);
});
it('updates serverless flag for endpoint policies with the flag already set', async () => {
it('does NOT update serverless flag for endpoint policies with the flag already set', async () => {
const packagePolicy1 = generatePackagePolicy(
policyFactory(undefined, undefined, undefined, undefined, undefined, true)
);
@ -97,7 +100,7 @@ describe('setEndpointPackagePolicyServerlessFlag', () => {
});
packagePolicyServiceMock.bulkCreate.mockImplementation();
await setEndpointPackagePolicyServerlessFlag(
await setEndpointPackagePolicyServerlessBillingFlags(
soClientMock,
esClientMock,
packagePolicyServiceMock
@ -111,6 +114,55 @@ describe('setEndpointPackagePolicyServerlessFlag', () => {
expect(packagePolicyServiceMock.bulkUpdate).not.toBeCalled();
});
it('correctly updates billable flag for endpoint policies', async () => {
// billable: false - serverless false
const packagePolicy1 = generatePackagePolicy(
policyFactory(undefined, undefined, undefined, undefined, undefined, false)
);
// billable: true - serverless + protections
const packagePolicy2 = generatePackagePolicy(
policyFactory(undefined, undefined, undefined, undefined, undefined, true)
);
// billable: false - serverless true but event collection only
const packagePolicy3 = generatePackagePolicy(
ensureOnlyEventCollectionIsAllowed(
policyFactory(undefined, undefined, undefined, undefined, undefined, true)
)
);
// ignored since flag already set
const packagePolicy4 = generatePackagePolicy(
policyFactory(undefined, undefined, undefined, undefined, undefined, true)
);
packagePolicyServiceMock.list.mockResolvedValue({
items: [packagePolicy1, packagePolicy2, packagePolicy3, packagePolicy4],
page: 1,
perPage: SO_SEARCH_LIMIT,
total: 4,
});
packagePolicyServiceMock.bulkCreate.mockImplementation();
await setEndpointPackagePolicyServerlessBillingFlags(
soClientMock,
esClientMock,
packagePolicyServiceMock
);
expect(packagePolicyServiceMock.list).toBeCalledWith(soClientMock, {
page: 1,
perPage: SO_SEARCH_LIMIT,
kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${FLEET_ENDPOINT_PACKAGE}`,
});
const expectedPolicy2 = cloneDeep(packagePolicy2);
expectedPolicy2!.inputs[0]!.config!.policy.value.meta.billable = true;
const expectedPolicies = [expectedPolicy2];
expect(packagePolicyServiceMock.bulkUpdate).toBeCalledWith(
soClientMock,
esClientMock,
expectedPolicies
);
});
it('batches properly when over perPage', async () => {
packagePolicyServiceMock.list
.mockResolvedValueOnce({
@ -127,7 +179,7 @@ describe('setEndpointPackagePolicyServerlessFlag', () => {
});
packagePolicyServiceMock.bulkCreate.mockImplementation();
await setEndpointPackagePolicyServerlessFlag(
await setEndpointPackagePolicyServerlessBillingFlags(
soClientMock,
esClientMock,
packagePolicyServiceMock

View file

@ -13,10 +13,12 @@ import {
PACKAGE_POLICY_SAVED_OBJECT_TYPE,
SO_SEARCH_LIMIT,
} from '@kbn/fleet-plugin/common';
import { isBillablePolicy } from '@kbn/security-solution-plugin/common/endpoint/models/policy_config_helpers';
// set all endpoint policies serverless flag to true
// set all endpoint policies serverless flag to true and
// billable flag depending on policy configuration
// required so that endpoint will write heartbeats
export async function setEndpointPackagePolicyServerlessFlag(
export async function setEndpointPackagePolicyServerlessBillingFlags(
soClient: SavedObjectsClientContract,
esClient: ElasticsearchClient,
packagePolicyService: PackagePolicyClient
@ -63,11 +65,10 @@ async function processBatch(
const updatedEndpointPackages = endpointPackagesResult.items
.filter(
(endpointPackage) =>
!(
endpointPackage?.inputs.every(
(input) => input.config?.policy?.value?.meta?.serverless ?? false
) ?? false
)
endpointPackage?.inputs.some((input) => {
const configMeta = input.config?.policy?.value?.meta ?? {};
return !configMeta.serverless || configMeta.billable === undefined;
}) ?? false
)
.map((endpointPackage) => ({
...endpointPackage,
@ -76,7 +77,8 @@ async function processBatch(
const policy = config.policy || {};
const policyValue = policy?.value || {};
const meta = policyValue?.meta || {};
return {
const updatedInput = {
...input,
config: {
...config,
@ -87,11 +89,17 @@ async function processBatch(
meta: {
...meta,
serverless: true,
billable: false,
},
},
},
},
};
updatedInput.config.policy.value.meta.billable = isBillablePolicy(
updatedInput.config.policy.value
);
return updatedInput;
}),
}));

View file

@ -30,7 +30,7 @@ import { getProductProductFeaturesConfigurator, getSecurityProductTier } from '.
import { METERING_TASK as ENDPOINT_METERING_TASK } from './endpoint/constants/metering';
import {
endpointMeteringService,
setEndpointPackagePolicyServerlessFlag,
setEndpointPackagePolicyServerlessBillingFlags,
} from './endpoint/services';
import { enableRuleActions } from './rules/enable_rule_actions';
import { NLPCleanupTask } from './task_manager/nlp_cleanup_task/nlp_cleanup_task';
@ -138,7 +138,7 @@ export class SecuritySolutionServerlessPlugin
this.nlpCleanupTask?.start({ taskManager: pluginsSetup.taskManager }).catch(() => {});
setEndpointPackagePolicyServerlessFlag(
setEndpointPackagePolicyServerlessBillingFlags(
internalSOClient,
internalESClient,
pluginsSetup.fleet.packagePolicyService