forked from elastic/kibana
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[Security Solution] Keep Endpoint policies up to date with license ch…
…anges (elastic#83992)
- Loading branch information
Showing
3 changed files
with
258 additions
and
1 deletion.
There are no files selected for viewing
133 changes: 133 additions & 0 deletions
133
x-pack/plugins/security_solution/server/endpoint/lib/policy/license_watch.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
}); | ||
}); |
116 changes: 116 additions & 0 deletions
116
x-pack/plugins/security_solution/server/endpoint/lib/policy/license_watch.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters