mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Security Solution] Keep Endpoint policies up to date with license changes (#83992)
This commit is contained in:
parent
d47c70cd53
commit
e1944342af
3 changed files with 258 additions and 1 deletions
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue