Skip to content

Commit 1a9bd78

Browse files
authored
[7.x] [Security Solution] Keep Endpoint policies up to date with license changes (#83992) (#84844)
1 parent 36c44f7 commit 1a9bd78

File tree

3 files changed

+258
-1
lines changed

3 files changed

+258
-1
lines changed
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import { Subject } from 'rxjs';
8+
import { loggingSystemMock, savedObjectsServiceMock } from 'src/core/server/mocks';
9+
import { LicenseService } from '../../../../common/license/license';
10+
import { createPackagePolicyServiceMock } from '../../../../../fleet/server/mocks';
11+
import { PolicyWatcher } from './license_watch';
12+
import { ILicense } from '../../../../../licensing/common/types';
13+
import { licenseMock } from '../../../../../licensing/common/licensing.mock';
14+
import { PackagePolicyServiceInterface } from '../../../../../fleet/server';
15+
import { PackagePolicy } from '../../../../../fleet/common';
16+
import { createPackagePolicyMock } from '../../../../../fleet/common/mocks';
17+
import { factory } from '../../../../common/endpoint/models/policy_config';
18+
import { PolicyConfig } from '../../../../common/endpoint/types';
19+
20+
const MockPPWithEndpointPolicy = (cb?: (p: PolicyConfig) => PolicyConfig): PackagePolicy => {
21+
const packagePolicy = createPackagePolicyMock();
22+
if (!cb) {
23+
// eslint-disable-next-line no-param-reassign
24+
cb = (p) => p;
25+
}
26+
const policyConfig = cb(factory());
27+
packagePolicy.inputs[0].config = { policy: { value: policyConfig } };
28+
return packagePolicy;
29+
};
30+
31+
describe('Policy-Changing license watcher', () => {
32+
const logger = loggingSystemMock.create().get('license_watch.test');
33+
const soStartMock = savedObjectsServiceMock.createStartContract();
34+
let packagePolicySvcMock: jest.Mocked<PackagePolicyServiceInterface>;
35+
36+
const Platinum = licenseMock.createLicense({ license: { type: 'platinum', mode: 'platinum' } });
37+
const Gold = licenseMock.createLicense({ license: { type: 'gold', mode: 'gold' } });
38+
const Basic = licenseMock.createLicense({ license: { type: 'basic', mode: 'basic' } });
39+
40+
beforeEach(() => {
41+
packagePolicySvcMock = createPackagePolicyServiceMock();
42+
});
43+
44+
it('is activated on license changes', () => {
45+
// mock a license-changing service to test reactivity
46+
const licenseEmitter: Subject<ILicense> = new Subject();
47+
const licenseService = new LicenseService();
48+
const pw = new PolicyWatcher(packagePolicySvcMock, soStartMock, logger);
49+
50+
// swap out watch function, just to ensure it gets called when a license change happens
51+
const mockWatch = jest.fn();
52+
pw.watch = mockWatch;
53+
54+
// licenseService is watching our subject for incoming licenses
55+
licenseService.start(licenseEmitter);
56+
pw.start(licenseService); // and the PolicyWatcher under test, uses that to subscribe as well
57+
58+
// Enqueue a license change!
59+
licenseEmitter.next(Platinum);
60+
61+
// policywatcher should have triggered
62+
expect(mockWatch.mock.calls.length).toBe(1);
63+
64+
pw.stop();
65+
licenseService.stop();
66+
licenseEmitter.complete();
67+
});
68+
69+
it('pages through all endpoint policies', async () => {
70+
const TOTAL = 247;
71+
72+
// set up the mocked package policy service to return and do what we want
73+
packagePolicySvcMock.list
74+
.mockResolvedValueOnce({
75+
items: Array.from({ length: 100 }, () => MockPPWithEndpointPolicy()),
76+
total: TOTAL,
77+
page: 1,
78+
perPage: 100,
79+
})
80+
.mockResolvedValueOnce({
81+
items: Array.from({ length: 100 }, () => MockPPWithEndpointPolicy()),
82+
total: TOTAL,
83+
page: 2,
84+
perPage: 100,
85+
})
86+
.mockResolvedValueOnce({
87+
items: Array.from({ length: TOTAL - 200 }, () => MockPPWithEndpointPolicy()),
88+
total: TOTAL,
89+
page: 3,
90+
perPage: 100,
91+
});
92+
93+
const pw = new PolicyWatcher(packagePolicySvcMock, soStartMock, logger);
94+
await pw.watch(Gold); // just manually trigger with a given license
95+
96+
expect(packagePolicySvcMock.list.mock.calls.length).toBe(3); // should have asked for 3 pages of resuts
97+
98+
// Assert: on the first call to packagePolicy.list, we asked for page 1
99+
expect(packagePolicySvcMock.list.mock.calls[0][1].page).toBe(1);
100+
expect(packagePolicySvcMock.list.mock.calls[1][1].page).toBe(2); // second call, asked for page 2
101+
expect(packagePolicySvcMock.list.mock.calls[2][1].page).toBe(3); // etc
102+
});
103+
104+
it('alters no-longer-licensed features', async () => {
105+
const CustomMessage = 'Custom string';
106+
107+
// mock a Policy with a higher-tiered feature enabled
108+
packagePolicySvcMock.list.mockResolvedValueOnce({
109+
items: [
110+
MockPPWithEndpointPolicy(
111+
(pc: PolicyConfig): PolicyConfig => {
112+
pc.windows.popup.malware.message = CustomMessage;
113+
return pc;
114+
}
115+
),
116+
],
117+
total: 1,
118+
page: 1,
119+
perPage: 100,
120+
});
121+
122+
const pw = new PolicyWatcher(packagePolicySvcMock, soStartMock, logger);
123+
124+
// emulate a license change below paid tier
125+
await pw.watch(Basic);
126+
127+
expect(packagePolicySvcMock.update).toHaveBeenCalled();
128+
expect(
129+
packagePolicySvcMock.update.mock.calls[0][2].inputs[0].config!.policy.value.windows.popup
130+
.malware.message
131+
).not.toEqual(CustomMessage);
132+
});
133+
});
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import { Subscription } from 'rxjs';
8+
9+
import {
10+
KibanaRequest,
11+
Logger,
12+
SavedObjectsClientContract,
13+
SavedObjectsServiceStart,
14+
} from 'src/core/server';
15+
import { PackagePolicy, PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '../../../../../fleet/common';
16+
import { PackagePolicyServiceInterface } from '../../../../../fleet/server';
17+
import { ILicense } from '../../../../../licensing/common/types';
18+
import {
19+
isEndpointPolicyValidForLicense,
20+
unsetPolicyFeaturesAboveLicenseLevel,
21+
} from '../../../../common/license/policy_config';
22+
import { isAtLeast, LicenseService } from '../../../../common/license/license';
23+
24+
export class PolicyWatcher {
25+
private logger: Logger;
26+
private soClient: SavedObjectsClientContract;
27+
private policyService: PackagePolicyServiceInterface;
28+
private subscription: Subscription | undefined;
29+
constructor(
30+
policyService: PackagePolicyServiceInterface,
31+
soStart: SavedObjectsServiceStart,
32+
logger: Logger
33+
) {
34+
this.policyService = policyService;
35+
this.soClient = this.makeInternalSOClient(soStart);
36+
this.logger = logger;
37+
}
38+
39+
/**
40+
* The policy watcher is not called as part of a HTTP request chain, where the
41+
* request-scoped SOClient could be passed down. It is called via license observable
42+
* changes. We are acting as the 'system' in response to license changes, so we are
43+
* intentionally using the system user here. Be very aware of what you are using this
44+
* client to do
45+
*/
46+
private makeInternalSOClient(soStart: SavedObjectsServiceStart): SavedObjectsClientContract {
47+
const fakeRequest = ({
48+
headers: {},
49+
getBasePath: () => '',
50+
path: '/',
51+
route: { settings: {} },
52+
url: { href: {} },
53+
raw: { req: { url: '/' } },
54+
} as unknown) as KibanaRequest;
55+
return soStart.getScopedClient(fakeRequest, { excludedWrappers: ['security'] });
56+
}
57+
58+
public start(licenseService: LicenseService) {
59+
this.subscription = licenseService.getLicenseInformation$()?.subscribe(this.watch.bind(this));
60+
}
61+
62+
public stop() {
63+
if (this.subscription) {
64+
this.subscription.unsubscribe();
65+
}
66+
}
67+
68+
public async watch(license: ILicense) {
69+
if (isAtLeast(license, 'platinum')) {
70+
return;
71+
}
72+
73+
let page = 1;
74+
let response: {
75+
items: PackagePolicy[];
76+
total: number;
77+
page: number;
78+
perPage: number;
79+
};
80+
do {
81+
try {
82+
response = await this.policyService.list(this.soClient, {
83+
page: page++,
84+
perPage: 100,
85+
kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name: endpoint`,
86+
});
87+
} catch (e) {
88+
this.logger.warn(
89+
`Unable to verify endpoint policies in line with license change: failed to fetch package policies: ${e.message}`
90+
);
91+
return;
92+
}
93+
response.items.forEach(async (policy) => {
94+
const policyConfig = policy.inputs[0].config?.policy.value;
95+
if (!isEndpointPolicyValidForLicense(policyConfig, license)) {
96+
policy.inputs[0].config!.policy.value = unsetPolicyFeaturesAboveLicenseLevel(
97+
policyConfig,
98+
license
99+
);
100+
try {
101+
await this.policyService.update(this.soClient, policy.id, policy);
102+
} catch (e) {
103+
// try again for transient issues
104+
try {
105+
await this.policyService.update(this.soClient, policy.id, policy);
106+
} catch (ee) {
107+
this.logger.warn(
108+
`Unable to remove platinum features from policy ${policy.id}: ${ee.message}`
109+
);
110+
}
111+
}
112+
}
113+
});
114+
} while (response.page * response.perPage < response.total);
115+
}
116+
}

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import {
7575
TelemetryPluginSetup,
7676
} from '../../../../src/plugins/telemetry/server';
7777
import { licenseService } from './lib/license/license';
78+
import { PolicyWatcher } from './endpoint/lib/policy/license_watch';
7879

7980
export interface SetupPlugins {
8081
alerts: AlertingSetup;
@@ -127,6 +128,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
127128

128129
private lists: ListPluginSetup | undefined; // TODO: can we create ListPluginStart?
129130
private licensing$!: Observable<ILicense>;
131+
private policyWatcher?: PolicyWatcher;
130132

131133
private manifestTask: ManifestTask | undefined;
132134
private exceptionsCache: LRU<string, Buffer>;
@@ -370,14 +372,20 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
370372
this.telemetryEventsSender.start(core, plugins.telemetry);
371373
this.licensing$ = plugins.licensing.license$;
372374
licenseService.start(this.licensing$);
373-
375+
this.policyWatcher = new PolicyWatcher(
376+
plugins.fleet!.packagePolicyService,
377+
core.savedObjects,
378+
this.logger
379+
);
380+
this.policyWatcher.start(licenseService);
374381
return {};
375382
}
376383

377384
public stop() {
378385
this.logger.debug('Stopping plugin');
379386
this.telemetryEventsSender.stop();
380387
this.endpointAppContextService.stop();
388+
this.policyWatcher?.stop();
381389
licenseService.stop();
382390
}
383391
}

0 commit comments

Comments
 (0)