[Fleet][Agent Tamper Protection] License watcher for agent policy (#160463)

## Summary

- [x] Adds an agent policy watcher to check if an agent policy is valid
for a specific license. If it is not valid, it will set the
platinum-only features (as of now, only tamper protection) to false.
- [x] Moves `generateNewAgentPolicyWithDefaults` up to `common/services`
- [x] Unit Tests

# Screenshot

![agent-policy-watch](345d4791-a665-4d22-873c-4013dd7ba709)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Candace Park 2023-07-05 15:23:05 -04:00 committed by GitHub
parent c9dc2bf308
commit 40d2f685d6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 431 additions and 14 deletions

View file

@ -5,9 +5,14 @@
* 2.0.
*/
import type { PostDeletePackagePoliciesResponse, NewPackagePolicy, PackagePolicy } from './types';
import type {
PostDeletePackagePoliciesResponse,
NewPackagePolicy,
PackagePolicy,
AgentPolicy,
} from './types';
import type { FleetAuthz } from './authz';
import { ENDPOINT_PRIVILEGES } from './constants';
import { dataTypes, ENDPOINT_PRIVILEGES } from './constants';
export const createNewPackagePolicyMock = (): NewPackagePolicy => {
return {
@ -105,3 +110,21 @@ export const createFleetAuthzMock = (): FleetAuthz => {
},
};
};
export const createAgentPolicyMock = (overrideProps?: Partial<AgentPolicy>): AgentPolicy => {
return {
id: 'agent-policy-1',
name: 'agent-policy-1',
description: 'an agent policy',
status: 'active',
namespace: 'default',
monitoring_enabled: Object.values(dataTypes),
inactivity_timeout: 1209600,
updated_at: '2023-06-30T16:03:38.159292',
updated_by: 'user-1',
revision: 1,
is_managed: false,
is_protected: false,
...overrideProps,
};
};

View file

@ -0,0 +1,53 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { pick } from 'lodash';
import { licenseMock } from '@kbn/licensing-plugin/common/licensing.mock';
import {
isAgentPolicyValidForLicense,
unsetAgentPolicyAccordingToLicenseLevel,
} from './agent_policy_config';
import { generateNewAgentPolicyWithDefaults } from './generate_new_agent_policy';
describe('agent policy config and licenses', () => {
const Platinum = licenseMock.createLicense({ license: { type: 'platinum', mode: 'platinum' } });
const Gold = licenseMock.createLicense({ license: { type: 'gold', mode: 'gold' } });
describe('isAgentPolicyValidForLicense', () => {
it('does not allow agent tampering protection to be turned on for gold and below licenses', () => {
const partialPolicy = { is_protected: true };
expect(isAgentPolicyValidForLicense(partialPolicy, Gold)).toBeFalsy();
});
it('allows agent tampering protection to be turned on for platinum licenses', () => {
const partialPolicy = { is_protected: true };
expect(isAgentPolicyValidForLicense(partialPolicy, Platinum)).toBeTruthy();
});
it('allows agent tampering protection to be turned off for platinum licenses', () => {
const partialPolicy = { is_protected: false };
expect(isAgentPolicyValidForLicense(partialPolicy, Platinum)).toBeTruthy();
});
});
describe('unsetAgentPolicyAccordingToLicenseLevel', () => {
it('resets all paid features to default if license is gold', () => {
const defaults = pick(generateNewAgentPolicyWithDefaults(), 'is_protected');
const partialPolicy = { is_protected: true };
const retPolicy = unsetAgentPolicyAccordingToLicenseLevel(partialPolicy, Gold);
expect(retPolicy).toEqual(defaults);
});
it('does not change paid features if license is platinum', () => {
const expected = pick(generateNewAgentPolicyWithDefaults(), 'is_protected');
const partialPolicy = { is_protected: false };
const expected2 = { is_protected: true };
const partialPolicy2 = { is_protected: true };
const retPolicy = unsetAgentPolicyAccordingToLicenseLevel(partialPolicy, Platinum);
expect(retPolicy).toEqual(expected);
const retPolicy2 = unsetAgentPolicyAccordingToLicenseLevel(partialPolicy2, Platinum);
expect(retPolicy2).toEqual(expected2);
});
});
});

View file

@ -0,0 +1,54 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { ILicense } from '@kbn/licensing-plugin/common/types';
import type { AgentPolicy } from '../types';
import { agentPolicyWithoutPaidFeatures } from './generate_new_agent_policy';
function isAgentTamperingPolicyValidForLicense(
policy: Partial<AgentPolicy>,
license: ILicense | null
) {
if (license && license.hasAtLeast('platinum')) {
// platinum allows agent tamper protection
return true;
}
const defaults = agentPolicyWithoutPaidFeatures(policy);
// only platinum or higher may modify agent tampering
if (policy.is_protected !== defaults.is_protected) {
return false;
}
return true;
}
export const isAgentPolicyValidForLicense = (
policy: Partial<AgentPolicy>,
license: ILicense | null
): boolean => {
return isAgentTamperingPolicyValidForLicense(policy, license);
};
/**
* Resets paid features in a AgentPolicy back to default values
* when unsupported by the given license level.
*/
export const unsetAgentPolicyAccordingToLicenseLevel = (
policy: Partial<AgentPolicy>,
license: ILicense | null
): Partial<AgentPolicy> => {
if (license && license.hasAtLeast('platinum')) {
return policy;
}
// set any license-gated features back to the defaults
return agentPolicyWithoutPaidFeatures(policy);
};

View file

@ -17,6 +17,7 @@ describe('generateNewAgentPolicyWithDefaults', () => {
namespace: 'default',
monitoring_enabled: ['logs', 'metrics'],
inactivity_timeout: 1209600,
is_protected: false,
});
});
@ -26,6 +27,7 @@ describe('generateNewAgentPolicyWithDefaults', () => {
description: 'test description',
namespace: 'test-namespace',
monitoring_enabled: ['logs'],
is_protected: true,
});
expect(newAgentPolicy).toEqual({
@ -34,6 +36,7 @@ describe('generateNewAgentPolicyWithDefaults', () => {
namespace: 'test-namespace',
monitoring_enabled: ['logs'],
inactivity_timeout: 1209600,
is_protected: true,
});
});
});

View file

@ -5,9 +5,9 @@
* 2.0.
*/
import { dataTypes } from '../../common/constants';
import { dataTypes } from '../constants';
import type { NewAgentPolicy } from '../types';
import type { AgentPolicy, NewAgentPolicy } from '../types';
const TWO_WEEKS_SECONDS = 1209600;
// create a new agent policy with the defaults set
@ -21,6 +21,16 @@ export function generateNewAgentPolicyWithDefaults(
namespace: 'default',
monitoring_enabled: Object.values(dataTypes),
inactivity_timeout: TWO_WEEKS_SECONDS,
is_protected: false,
...overrideProps,
};
}
export function agentPolicyWithoutPaidFeatures(
agentPolicy: Partial<AgentPolicy>
): Partial<AgentPolicy> {
return {
...agentPolicy,
is_protected: false,
};
}

View file

@ -64,3 +64,13 @@ export { getAllowedOutputTypeForPolicy } from './output_helpers';
export { agentStatusesToSummary } from './agent_statuses_to_summary';
export { policyHasFleetServer, policyHasAPMIntegration } from './agent_policies_helpers';
export {
generateNewAgentPolicyWithDefaults,
agentPolicyWithoutPaidFeatures,
} from './generate_new_agent_policy';
export {
isAgentPolicyValidForLicense,
unsetAgentPolicyAccordingToLicenseLevel,
} from './agent_policy_config';

View file

@ -22,11 +22,12 @@ import { FormattedMessage } from '@kbn/i18n-react';
import styled from 'styled-components';
import { i18n } from '@kbn/i18n';
import { generateNewAgentPolicyWithDefaults } from '../../../services';
import type { AgentPolicy, NewAgentPolicy } from '../../../types';
import { sendCreateAgentPolicy, useStartServices } from '../../../hooks';
import { generateNewAgentPolicyWithDefaults } from '../../../../../../common/services/generate_new_agent_policy';
import { agentPolicyFormValidation } from '.';
import { AgentPolicyAdvancedOptionsContent } from './agent_policy_advanced_fields';

View file

@ -8,14 +8,14 @@
import { useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { generateNewAgentPolicyWithDefaults } from '../../../../../../../../common/services/generate_new_agent_policy';
import {
sendCreateAgentPolicy,
sendGetOneAgentPolicy,
sendGetEnrollmentAPIKeys,
} from '../../../../../../../hooks';
import { generateNewAgentPolicyWithDefaults } from '../../../../../../../services';
import type { AgentPolicy, NewAgentPolicy, EnrollmentAPIKey } from '../../../../../../../types';
interface UseGetAgentPolicyOrDefaultResponse {

View file

@ -401,6 +401,7 @@ describe('when on the package policy create page', () => {
name: 'Agent policy 2',
namespace: 'default',
inactivity_timeout: 1209600,
is_protected: false,
},
{ withSysMonitoring: false }
);
@ -432,6 +433,7 @@ describe('when on the package policy create page', () => {
name: 'Agent policy 2',
namespace: 'default',
inactivity_timeout: 1209600,
is_protected: false,
},
{ withSysMonitoring: true }
);

View file

@ -30,7 +30,6 @@ import {
import { useCancelAddPackagePolicy } from '../hooks';
import { splitPkgKey } from '../../../../../../../common/services';
import { generateNewAgentPolicyWithDefaults } from '../../../../services';
import type { NewAgentPolicy } from '../../../../types';
import { useConfig, sendGetAgentStatus, useGetPackageInfoByKeyQuery } from '../../../../hooks';
import {
@ -56,6 +55,8 @@ import {
StepSelectHosts,
} from '../components';
import { generateNewAgentPolicyWithDefaults } from '../../../../../../../common/services/generate_new_agent_policy';
import { CreatePackagePolicySinglePageLayout, PostInstallAddAgentModal } from './components';
import { useDevToolsRequest, useOnSubmit } from './hooks';
import { PostInstallCloudFormationModal } from './components/post_install_cloud_formation_modal';

View file

@ -29,10 +29,8 @@ import { useAuthz, useStartServices, sendCreateAgentPolicy } from '../../../../h
import { AgentPolicyForm, agentPolicyFormValidation } from '../../components';
import { DevtoolsRequestFlyoutButton } from '../../../../components';
import { generateCreateAgentPolicyDevToolsRequest } from '../../services';
import {
ExperimentalFeaturesService,
generateNewAgentPolicyWithDefaults,
} from '../../../../services';
import { ExperimentalFeaturesService } from '../../../../services';
import { generateNewAgentPolicyWithDefaults } from '../../../../../../../common/services/generate_new_agent_policy';
const FlyoutWithHigherZIndex = styled(EuiFlyout)`
z-index: ${(props) => props.theme.eui.euiZLevel5};

View file

@ -48,6 +48,5 @@ export { isPackageUpdatable } from './is_package_updatable';
export { pkgKeyFromPackageInfo } from './pkg_key_from_package_info';
export { createExtensionRegistrationCallback } from './ui_extensions';
export { incrementPolicyName } from './increment_policy_name';
export { generateNewAgentPolicyWithDefaults } from './generate_new_agent_policy';
export { getCloudFormationTemplateUrlFromPackagePolicy } from './get_cloud_formation_template_url_from_package_policy';
export { getCloudFormationTemplateUrlFromAgentPolicy } from './get_cloud_formation_template_url_from_agent_policy';

View file

@ -127,6 +127,7 @@ import {
} from './services/security/uninstall_token_service';
import { FleetActionsClient, type FleetActionsClientInterface } from './services/actions';
import type { FilesClientFactory } from './services/files/types';
import { PolicyWatcher } from './services/agent_policy_watch';
export interface FleetSetupDeps {
security: SecurityPluginSetup;
@ -256,6 +257,7 @@ export class FleetPlugin
private agentService?: AgentService;
private packageService?: PackageService;
private packagePolicyService?: PackagePolicyService;
private policyWatcher?: PolicyWatcher;
constructor(private readonly initializerContext: PluginInitializerContext) {
this.config$ = this.initializerContext.config.create<FleetConfigType>();
@ -491,7 +493,6 @@ export class FleetPlugin
uninstallTokenService,
});
licenseService.start(plugins.licensing.license$);
this.telemetryEventsSender.start(plugins.telemetry, core);
this.bulkActionsResolver?.start(plugins.taskManager);
this.fleetUsageSender?.start(plugins.taskManager);
@ -500,6 +501,10 @@ export class FleetPlugin
const logger = appContextService.getLogger();
this.policyWatcher = new PolicyWatcher(core.savedObjects, core.elasticsearch, logger);
this.policyWatcher.start(licenseService);
const fleetSetupPromise = (async () => {
try {
// Fleet remains `available` during setup as to excessively delay Kibana's boot process.
@ -591,6 +596,7 @@ export class FleetPlugin
public async stop() {
appContextService.stop();
this.policyWatcher?.stop();
licenseService.stop();
this.telemetryEventsSender.stop();
this.fleetStatus$.complete();

View file

@ -0,0 +1,114 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
elasticsearchServiceMock,
loggingSystemMock,
savedObjectsServiceMock,
} from '@kbn/core/server/mocks';
import { licenseMock } from '@kbn/licensing-plugin/common/licensing.mock';
import type { ILicense } from '@kbn/licensing-plugin/common/types';
import { Subject } from 'rxjs';
import { LicenseService } from '../../common/services';
import { createAgentPolicyMock } from '../../common/mocks';
import { PolicyWatcher } from './agent_policy_watch';
import { agentPolicyService } from './agent_policy';
jest.mock('./agent_policy');
const agentPolicySvcMock = agentPolicyService as jest.Mocked<typeof agentPolicyService>;
describe('Agent Policy-Changing license watcher', () => {
const logger = loggingSystemMock.create().get('license_watch.test');
const soStartMock = savedObjectsServiceMock.createStartContract();
const esStartMock = elasticsearchServiceMock.createStart();
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' } });
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(soStartMock, esStartMock, 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 agent policies', async () => {
const TOTAL = 247;
// set up the mocked agent policy service to return and do what we want
agentPolicySvcMock.list
.mockResolvedValueOnce({
items: Array.from({ length: 100 }, () => createAgentPolicyMock()),
total: TOTAL,
page: 1,
perPage: 100,
})
.mockResolvedValueOnce({
items: Array.from({ length: 100 }, () => createAgentPolicyMock()),
total: TOTAL,
page: 2,
perPage: 100,
})
.mockResolvedValueOnce({
items: Array.from({ length: TOTAL - 200 }, () => createAgentPolicyMock()),
total: TOTAL,
page: 3,
perPage: 100,
});
const pw = new PolicyWatcher(soStartMock, esStartMock, logger);
await pw.watch(Gold); // just manually trigger with a given license
expect(agentPolicySvcMock.list.mock.calls.length).toBe(3); // should have asked for 3 pages of resuts
// Assert: on the first call to agentPolicy.list, we asked for page 1
expect(agentPolicySvcMock.list.mock.calls[0][1].page).toBe(1);
expect(agentPolicySvcMock.list.mock.calls[1][1].page).toBe(2); // second call, asked for page 2
expect(agentPolicySvcMock.list.mock.calls[2][1].page).toBe(3); // etc
});
it('alters no-longer-licensed features', async () => {
// mock an agent policy with agent tamper protection enabled
agentPolicySvcMock.list.mockResolvedValueOnce({
items: [createAgentPolicyMock({ is_protected: true })],
total: 1,
page: 1,
perPage: 100,
});
const pw = new PolicyWatcher(soStartMock, esStartMock, logger);
// emulate a license change below paid tier
await pw.watch(Basic);
expect(agentPolicySvcMock.update).toHaveBeenCalled();
expect(agentPolicySvcMock.update.mock.calls[0][3].is_protected).toEqual(false);
});
});

View file

@ -0,0 +1,143 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Subscription } from 'rxjs';
import type {
ElasticsearchClient,
ElasticsearchServiceStart,
KibanaRequest,
Logger,
SavedObjectsClientContract,
SavedObjectsServiceStart,
} from '@kbn/core/server';
import { SECURITY_EXTENSION_ID } from '@kbn/core-saved-objects-server';
import type { ILicense } from '@kbn/licensing-plugin/common/types';
import { pick } from 'lodash';
import type { LicenseService } from '../../common/services/license';
import type { AgentPolicy } from '../../common';
import { AGENT_POLICY_SAVED_OBJECT_TYPE } from '../../common';
import {
isAgentPolicyValidForLicense,
unsetAgentPolicyAccordingToLicenseLevel,
} from '../../common/services/agent_policy_config';
import { agentPolicyService } from './agent_policy';
export class PolicyWatcher {
private logger: Logger;
private esClient: ElasticsearchClient;
private subscription: Subscription | undefined;
private soStart: SavedObjectsServiceStart;
constructor(
soStart: SavedObjectsServiceStart,
esStart: ElasticsearchServiceStart,
logger: Logger
) {
this.esClient = esStart.client.asInternalUser;
this.logger = logger;
this.soStart = soStart;
}
/**
* 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, { excludedExtensions: [SECURITY_EXTENSION_ID] });
}
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) {
let page = 1;
let response: {
items: AgentPolicy[];
total: number;
page: number;
perPage: number;
};
do {
try {
response = await agentPolicyService.list(this.makeInternalSOClient(this.soStart), {
page: page++,
perPage: 100,
kuery: AGENT_POLICY_SAVED_OBJECT_TYPE,
});
} catch (e) {
this.logger.warn(
`Unable to verify agent policies in line with license change: failed to fetch agent policies: ${e.message}`
);
return;
}
const updatedPolicyIds: string[] = [];
for (const policy of response.items as AgentPolicy[]) {
let updatePolicy = pick(policy, ['is_protected']) as Partial<AgentPolicy>;
try {
if (!isAgentPolicyValidForLicense(updatePolicy, license)) {
updatePolicy = unsetAgentPolicyAccordingToLicenseLevel(updatePolicy, license);
try {
this.logger.info('Updating agent policies per license change');
await agentPolicyService.update(
this.makeInternalSOClient(this.soStart),
this.esClient,
policy.id,
updatePolicy
);
// accumulate list of policies updated
updatedPolicyIds.push(policy.id);
} catch (e) {
// try again for transient issues
try {
await agentPolicyService.update(
this.makeInternalSOClient(this.soStart),
this.esClient,
policy.id,
updatePolicy
);
} catch (ee) {
this.logger.warn(
`Unable to remove platinum features from agent policy ${policy.id}`
);
this.logger.warn(ee);
}
}
}
} catch (error) {
this.logger.warn(
`Failure while attempting to verify features for agent policy [${policy.id}]`
);
this.logger.warn(error);
}
}
this.logger.info(`Agent policies updated by license change: [${updatedPolicyIds.join()}]`);
} while (response.page * response.perPage < response.total);
}
}