[Security Solution] Keep Endpoint policies up to date with license changes (#83992)

This commit is contained in:
Dan Panzarella 2020-12-02 17:30:44 -05:00 committed by GitHub
parent d47c70cd53
commit e1944342af
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 258 additions and 1 deletions

View file

@ -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;
* you may not use this file except in compliance with the Elastic License.
*/
import { Subject } from 'rxjs';
import { loggingSystemMock, savedObjectsServiceMock } from 'src/core/server/mocks';
import { LicenseService } from '../../../../common/license/license';
import { createPackagePolicyServiceMock } from '../../../../../fleet/server/mocks';
import { PolicyWatcher } from './license_watch';
import { ILicense } from '../../../../../licensing/common/types';
import { licenseMock } from '../../../../../licensing/common/licensing.mock';
import { PackagePolicyServiceInterface } from '../../../../../fleet/server';
import { PackagePolicy } from '../../../../../fleet/common';
import { createPackagePolicyMock } from '../../../../../fleet/common/mocks';
import { factory } from '../../../../common/endpoint/models/policy_config';
import { PolicyConfig } from '../../../../common/endpoint/types';
const MockPPWithEndpointPolicy = (cb?: (p: PolicyConfig) => PolicyConfig): PackagePolicy => {
const packagePolicy = createPackagePolicyMock();
if (!cb) {
// eslint-disable-next-line no-param-reassign
cb = (p) => p;
}
const policyConfig = cb(factory());
packagePolicy.inputs[0].config = { policy: { value: policyConfig } };
return packagePolicy;
};
describe('Policy-Changing license watcher', () => {
const logger = loggingSystemMock.create().get('license_watch.test');
const soStartMock = savedObjectsServiceMock.createStartContract();
let packagePolicySvcMock: jest.Mocked<PackagePolicyServiceInterface>;
const Platinum = licenseMock.createLicense({ license: { type: 'platinum', mode: 'platinum' } });
const Gold = licenseMock.createLicense({ license: { type: 'gold', mode: 'gold' } });
const Basic = licenseMock.createLicense({ license: { type: 'basic', mode: 'basic' } });
beforeEach(() => {
packagePolicySvcMock = createPackagePolicyServiceMock();
});
it('is activated on license changes', () => {
// mock a license-changing service to test reactivity
const licenseEmitter: Subject<ILicense> = new Subject();
const licenseService = new LicenseService();
const pw = new PolicyWatcher(packagePolicySvcMock, soStartMock, logger);
// swap out watch function, just to ensure it gets called when a license change happens
const mockWatch = jest.fn();
pw.watch = mockWatch;
// licenseService is watching our subject for incoming licenses
licenseService.start(licenseEmitter);
pw.start(licenseService); // and the PolicyWatcher under test, uses that to subscribe as well
// Enqueue a license change!
licenseEmitter.next(Platinum);
// policywatcher should have triggered
expect(mockWatch.mock.calls.length).toBe(1);
pw.stop();
licenseService.stop();
licenseEmitter.complete();
});
it('pages through all endpoint policies', async () => {
const TOTAL = 247;
// set up the mocked package policy service to return and do what we want
packagePolicySvcMock.list
.mockResolvedValueOnce({
items: Array.from({ length: 100 }, () => MockPPWithEndpointPolicy()),
total: TOTAL,
page: 1,
perPage: 100,
})
.mockResolvedValueOnce({
items: Array.from({ length: 100 }, () => MockPPWithEndpointPolicy()),
total: TOTAL,
page: 2,
perPage: 100,
})
.mockResolvedValueOnce({
items: Array.from({ length: TOTAL - 200 }, () => MockPPWithEndpointPolicy()),
total: TOTAL,
page: 3,
perPage: 100,
});
const pw = new PolicyWatcher(packagePolicySvcMock, soStartMock, logger);
await pw.watch(Gold); // just manually trigger with a given license
expect(packagePolicySvcMock.list.mock.calls.length).toBe(3); // should have asked for 3 pages of resuts
// Assert: on the first call to packagePolicy.list, we asked for page 1
expect(packagePolicySvcMock.list.mock.calls[0][1].page).toBe(1);
expect(packagePolicySvcMock.list.mock.calls[1][1].page).toBe(2); // second call, asked for page 2
expect(packagePolicySvcMock.list.mock.calls[2][1].page).toBe(3); // etc
});
it('alters no-longer-licensed features', async () => {
const CustomMessage = 'Custom string';
// mock a Policy with a higher-tiered feature enabled
packagePolicySvcMock.list.mockResolvedValueOnce({
items: [
MockPPWithEndpointPolicy(
(pc: PolicyConfig): PolicyConfig => {
pc.windows.popup.malware.message = CustomMessage;
return pc;
}
),
],
total: 1,
page: 1,
perPage: 100,
});
const pw = new PolicyWatcher(packagePolicySvcMock, soStartMock, logger);
// emulate a license change below paid tier
await pw.watch(Basic);
expect(packagePolicySvcMock.update).toHaveBeenCalled();
expect(
packagePolicySvcMock.update.mock.calls[0][2].inputs[0].config!.policy.value.windows.popup
.malware.message
).not.toEqual(CustomMessage);
});
});

View file

@ -0,0 +1,116 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Subscription } from 'rxjs';
import {
KibanaRequest,
Logger,
SavedObjectsClientContract,
SavedObjectsServiceStart,
} from 'src/core/server';
import { PackagePolicy, PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '../../../../../fleet/common';
import { PackagePolicyServiceInterface } from '../../../../../fleet/server';
import { ILicense } from '../../../../../licensing/common/types';
import {
isEndpointPolicyValidForLicense,
unsetPolicyFeaturesAboveLicenseLevel,
} from '../../../../common/license/policy_config';
import { isAtLeast, LicenseService } from '../../../../common/license/license';
export class PolicyWatcher {
private logger: Logger;
private soClient: SavedObjectsClientContract;
private policyService: PackagePolicyServiceInterface;
private subscription: Subscription | undefined;
constructor(
policyService: PackagePolicyServiceInterface,
soStart: SavedObjectsServiceStart,
logger: Logger
) {
this.policyService = policyService;
this.soClient = this.makeInternalSOClient(soStart);
this.logger = logger;
}
/**
* The policy watcher is not called as part of a HTTP request chain, where the
* request-scoped SOClient could be passed down. It is called via license observable
* changes. We are acting as the 'system' in response to license changes, so we are
* intentionally using the system user here. Be very aware of what you are using this
* client to do
*/
private makeInternalSOClient(soStart: SavedObjectsServiceStart): SavedObjectsClientContract {
const fakeRequest = ({
headers: {},
getBasePath: () => '',
path: '/',
route: { settings: {} },
url: { href: {} },
raw: { req: { url: '/' } },
} as unknown) as KibanaRequest;
return soStart.getScopedClient(fakeRequest, { excludedWrappers: ['security'] });
}
public start(licenseService: LicenseService) {
this.subscription = licenseService.getLicenseInformation$()?.subscribe(this.watch.bind(this));
}
public stop() {
if (this.subscription) {
this.subscription.unsubscribe();
}
}
public async watch(license: ILicense) {
if (isAtLeast(license, 'platinum')) {
return;
}
let page = 1;
let response: {
items: PackagePolicy[];
total: number;
page: number;
perPage: number;
};
do {
try {
response = await this.policyService.list(this.soClient, {
page: page++,
perPage: 100,
kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name: endpoint`,
});
} catch (e) {
this.logger.warn(
`Unable to verify endpoint policies in line with license change: failed to fetch package policies: ${e.message}`
);
return;
}
response.items.forEach(async (policy) => {
const policyConfig = policy.inputs[0].config?.policy.value;
if (!isEndpointPolicyValidForLicense(policyConfig, license)) {
policy.inputs[0].config!.policy.value = unsetPolicyFeaturesAboveLicenseLevel(
policyConfig,
license
);
try {
await this.policyService.update(this.soClient, policy.id, policy);
} catch (e) {
// try again for transient issues
try {
await this.policyService.update(this.soClient, policy.id, policy);
} catch (ee) {
this.logger.warn(
`Unable to remove platinum features from policy ${policy.id}: ${ee.message}`
);
}
}
}
});
} while (response.page * response.perPage < response.total);
}
}

View file

@ -75,6 +75,7 @@ import {
TelemetryPluginSetup,
} from '../../../../src/plugins/telemetry/server';
import { licenseService } from './lib/license/license';
import { PolicyWatcher } from './endpoint/lib/policy/license_watch';
export interface SetupPlugins {
alerts: AlertingSetup;
@ -127,6 +128,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
private lists: ListPluginSetup | undefined; // TODO: can we create ListPluginStart?
private licensing$!: Observable<ILicense>;
private policyWatcher?: PolicyWatcher;
private manifestTask: ManifestTask | undefined;
private exceptionsCache: LRU<string, Buffer>;
@ -370,7 +372,12 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
this.telemetryEventsSender.start(core, plugins.telemetry);
this.licensing$ = plugins.licensing.license$;
licenseService.start(this.licensing$);
this.policyWatcher = new PolicyWatcher(
plugins.fleet!.packagePolicyService,
core.savedObjects,
this.logger
);
this.policyWatcher.start(licenseService);
return {};
}
@ -378,6 +385,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
this.logger.debug('Stopping plugin');
this.telemetryEventsSender.stop();
this.endpointAppContextService.stop();
this.policyWatcher?.stop();
licenseService.stop();
}
}