[Security solution] [Endpoint] Remove linked policy from trusted apps when removing endpoint integration (#108347)

* Remove policy from trusted app when this is removed from fleet
* Fleet: run package delete external callbacks when the Agent Policy is deleted
This commit is contained in:
David Sánchez 2021-08-19 19:45:55 +02:00 committed by GitHub
parent 8d1ebea7db
commit 79e63cc654
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 340 additions and 24 deletions

View file

@ -7,8 +7,7 @@
import { APMPlugin, APMRouteHandlerResources } from '../..';
import {
ExternalCallback,
PostPackagePolicyDeleteCallback,
PostPackagePolicyCreateCallback,
PutPackagePolicyUpdateCallback,
} from '../../../../fleet/server';
import {
@ -60,7 +59,9 @@ export async function registerFleetPolicyCallbacks({
});
}
type ExternalCallbackParams = Parameters<ExternalCallback[1]>;
type ExternalCallbackParams =
| Parameters<PostPackagePolicyCreateCallback>
| Parameters<PutPackagePolicyUpdateCallback>;
export type PackagePolicy = NewPackagePolicy | UpdatePackagePolicy;
type Context = ExternalCallbackParams[1];
type Request = ExternalCallbackParams[2];
@ -81,7 +82,7 @@ function registerPackagePolicyExternalCallback({
logger: NonNullable<APMPlugin['logger']>;
}) {
const callbackFn:
| PostPackagePolicyDeleteCallback
| PostPackagePolicyCreateCallback
| PutPackagePolicyUpdateCallback = async (
packagePolicy: PackagePolicy,
context: Context,

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import type { NewPackagePolicy, PackagePolicy } from './types';
import type { NewPackagePolicy, PackagePolicy, DeletePackagePoliciesResponse } from './types';
export const createNewPackagePolicyMock = (): NewPackagePolicy => {
return {
@ -45,3 +45,14 @@ export const createPackagePolicyMock = (): PackagePolicy => {
],
};
};
export const deletePackagePolicyMock = (): DeletePackagePoliciesResponse => {
const newPackagePolicy = createNewPackagePolicyMock();
return [
{
id: 'c6d16e42-c32d-4dce-8a88-113cfe276ad1',
success: true,
package: newPackagePolicy.package,
},
];
};

View file

@ -62,7 +62,7 @@ export const xpackMocks = {
createRequestHandlerContext: createCoreRequestHandlerContextMock,
};
export const createPackagePolicyServiceMock = () => {
export const createPackagePolicyServiceMock = (): jest.Mocked<PackagePolicyServiceInterface> => {
return {
compilePackagePolicyInputs: jest.fn(),
buildPackagePolicyFromPackage: jest.fn(),
@ -75,10 +75,11 @@ export const createPackagePolicyServiceMock = () => {
listIds: jest.fn(),
update: jest.fn(),
runExternalCallbacks: jest.fn(),
runDeleteExternalCallbacks: jest.fn(),
upgrade: jest.fn(),
getUpgradeDryRunDiff: jest.fn(),
getUpgradePackagePolicyInfo: jest.fn(),
} as jest.Mocked<PackagePolicyServiceInterface>;
};
};
/**

View file

@ -181,6 +181,7 @@ export const deletePackagePolicyHandler: RequestHandler<
} catch (error) {
const logger = appContextService.getLogger();
logger.error(`An error occurred executing external callback: ${error}`);
logger.error(error);
}
return response.ok({
body,

View file

@ -12,6 +12,9 @@ import type { AgentPolicy, NewAgentPolicy, Output } from '../types';
import { agentPolicyService } from './agent_policy';
import { agentPolicyUpdateEventHandler } from './agent_policy_update';
import { getAgentsByKuery } from './agents';
import { packagePolicyService } from './package_policy';
function getSavedObjectMock(agentPolicyAttributes: any) {
const mock = savedObjectsClientMock.create();
mock.get.mockImplementation(async (type: string, id: string) => {
@ -63,6 +66,8 @@ jest.mock('./output', () => {
});
jest.mock('./agent_policy_update');
jest.mock('./agents');
jest.mock('./package_policy');
function getAgentPolicyUpdateMock() {
return (agentPolicyUpdateEventHandler as unknown) as jest.Mock<
@ -123,6 +128,36 @@ describe('agent policy', () => {
});
});
describe('delete', () => {
let soClient: ReturnType<typeof savedObjectsClientMock.create>;
let esClient: ReturnType<typeof elasticsearchServiceMock.createClusterClient>['asInternalUser'];
beforeEach(() => {
soClient = getSavedObjectMock({ revision: 1, package_policies: ['package-1'] });
esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
(getAgentsByKuery as jest.Mock).mockResolvedValue({
agents: [],
total: 0,
page: 1,
perPage: 10,
});
(packagePolicyService.delete as jest.Mock).mockResolvedValue([
{
id: 'package-1',
},
]);
});
it('should run package policy delete external callbacks', async () => {
await agentPolicyService.delete(soClient, esClient, 'mocked');
expect(packagePolicyService.runDeleteExternalCallbacks).toHaveBeenCalledWith([
{ id: 'package-1' },
]);
});
});
describe('bumpRevision', () => {
it('should call agentPolicyUpdateEventHandler with updated event once', async () => {
const soClient = getSavedObjectMock({

View file

@ -45,6 +45,7 @@ import type {
FleetServerPolicy,
Installation,
Output,
DeletePackagePoliciesResponse,
} from '../../common';
import { AgentPolicyNameExistsError, HostedAgentPolicyRestrictionRelatedError } from '../errors';
import {
@ -616,7 +617,7 @@ class AgentPolicyService {
}
if (agentPolicy.package_policies && agentPolicy.package_policies.length) {
await packagePolicyService.delete(
const deletedPackagePolicies: DeletePackagePoliciesResponse = await packagePolicyService.delete(
soClient,
esClient,
agentPolicy.package_policies as string[],
@ -624,6 +625,13 @@ class AgentPolicyService {
skipUnassignFromAgentPolicies: true,
}
);
try {
await packagePolicyService.runDeleteExternalCallbacks(deletedPackagePolicies);
} catch (error) {
const logger = appContextService.getLogger();
logger.error(`An error occurred executing external callback: ${error}`);
logger.error(error);
}
}
if (agentPolicy.is_preconfigured) {

View file

@ -20,6 +20,12 @@ import type { PutPackagePolicyUpdateCallback, PostPackagePolicyCreateCallback }
import { createAppContextStartContractMock, xpackMocks } from '../mocks';
import type { PostPackagePolicyDeleteCallback } from '../types';
import type { DeletePackagePoliciesResponse } from '../../common';
import { IngestManagerError } from '../errors';
import { packagePolicyService } from './package_policy';
import { appContextService } from './app_context';
@ -815,6 +821,78 @@ describe('Package policy service', () => {
});
});
describe('runDeleteExternalCallbacks', () => {
let callbackOne: jest.MockedFunction<PostPackagePolicyDeleteCallback>;
let callbackTwo: jest.MockedFunction<PostPackagePolicyDeleteCallback>;
let callingOrder: string[];
let deletedPackagePolicies: DeletePackagePoliciesResponse;
beforeEach(() => {
appContextService.start(createAppContextStartContractMock());
callingOrder = [];
deletedPackagePolicies = [
{ id: 'a', success: true },
{ id: 'a', success: true },
];
callbackOne = jest.fn(async (deletedPolicies) => {
callingOrder.push('one');
});
callbackTwo = jest.fn(async (deletedPolicies) => {
callingOrder.push('two');
});
appContextService.addExternalCallback('postPackagePolicyDelete', callbackOne);
appContextService.addExternalCallback('postPackagePolicyDelete', callbackTwo);
});
afterEach(() => {
appContextService.stop();
});
it('should execute external callbacks', async () => {
await packagePolicyService.runDeleteExternalCallbacks(deletedPackagePolicies);
expect(callbackOne).toHaveBeenCalledWith(deletedPackagePolicies);
expect(callbackTwo).toHaveBeenCalledWith(deletedPackagePolicies);
expect(callingOrder).toEqual(['one', 'two']);
});
it("should execute all external callbacks even if one throw's", async () => {
callbackOne.mockImplementation(async (deletedPolicies) => {
callingOrder.push('one');
throw new Error('foo');
});
await expect(
packagePolicyService.runDeleteExternalCallbacks(deletedPackagePolicies)
).rejects.toThrow(IngestManagerError);
expect(callingOrder).toEqual(['one', 'two']);
});
it('should provide an array of errors encountered by running external callbacks', async () => {
let error: IngestManagerError;
const callbackOneError = new Error('foo 1');
const callbackTwoError = new Error('foo 2');
callbackOne.mockImplementation(async (deletedPolicies) => {
callingOrder.push('one');
throw callbackOneError;
});
callbackTwo.mockImplementation(async (deletedPolicies) => {
callingOrder.push('two');
throw callbackTwoError;
});
await packagePolicyService.runDeleteExternalCallbacks(deletedPackagePolicies).catch((e) => {
error = e;
});
expect(error!.message).toEqual(
'2 encountered while executing package delete external callbacks'
);
expect(error!.meta).toEqual([callbackOneError, callbackTwoError]);
expect(callingOrder).toEqual(['one', 'two']);
});
});
describe('runExternalCallbacks', () => {
let context: ReturnType<typeof xpackMocks.createRequestHandlerContext>;
let request: KibanaRequest;

View file

@ -428,9 +428,9 @@ class PackagePolicyService {
name: packagePolicy.name,
success: true,
package: {
name: packagePolicy.name,
title: '',
version: packagePolicy.version || '',
name: packagePolicy.package?.name || '',
title: packagePolicy.package?.title || '',
version: packagePolicy.package?.version || '',
},
});
} catch (error) {
@ -642,7 +642,9 @@ class PackagePolicyService {
public async runExternalCallbacks<A extends ExternalCallback[0]>(
externalCallbackType: A,
packagePolicy: NewPackagePolicy | DeletePackagePoliciesResponse,
packagePolicy: A extends 'postPackagePolicyDelete'
? DeletePackagePoliciesResponse
: NewPackagePolicy,
context: RequestHandlerContext,
request: KibanaRequest
): Promise<A extends 'postPackagePolicyDelete' ? void : NewPackagePolicy>;
@ -653,14 +655,7 @@ class PackagePolicyService {
request: KibanaRequest
): Promise<NewPackagePolicy | void> {
if (externalCallbackType === 'postPackagePolicyDelete') {
const externalCallbacks = appContextService.getExternalCallbacks(externalCallbackType);
if (externalCallbacks && externalCallbacks.size > 0) {
for (const callback of externalCallbacks) {
if (Array.isArray(packagePolicy)) {
await callback(packagePolicy, context, request);
}
}
}
return await this.runDeleteExternalCallbacks(packagePolicy as DeletePackagePoliciesResponse);
} else {
if (!Array.isArray(packagePolicy)) {
let newData = packagePolicy;
@ -682,6 +677,32 @@ class PackagePolicyService {
}
}
}
public async runDeleteExternalCallbacks(
deletedPackagePolicies: DeletePackagePoliciesResponse
): Promise<void> {
const externalCallbacks = appContextService.getExternalCallbacks('postPackagePolicyDelete');
const errorsThrown: Error[] = [];
if (externalCallbacks && externalCallbacks.size > 0) {
for (const callback of externalCallbacks) {
// Failures from an external callback should not prevent other external callbacks from being
// executed. Errors (if any) will be collected and `throw`n after processing the entire set
try {
await callback(deletedPackagePolicies);
} catch (error) {
errorsThrown.push(error);
}
}
if (errorsThrown.length > 0) {
throw new IngestManagerError(
`${errorsThrown.length} encountered while executing package delete external callbacks`,
errorsThrown
);
}
}
}
}
function assignStreamIdToInput(packagePolicyId: string, input: NewPackagePolicyInput) {

View file

@ -7,6 +7,8 @@
import type { KibanaRequest, RequestHandlerContext } from 'kibana/server';
import type { DeepReadonly } from 'utility-types';
import type {
DeletePackagePoliciesResponse,
NewPackagePolicy,
@ -14,9 +16,7 @@ import type {
} from '../../common';
export type PostPackagePolicyDeleteCallback = (
deletedPackagePolicies: DeletePackagePoliciesResponse,
context: RequestHandlerContext,
request: KibanaRequest
deletedPackagePolicies: DeepReadonly<DeletePackagePoliciesResponse>
) => Promise<void>;
export type PostPackagePolicyCreateCallback = (

View file

@ -22,6 +22,7 @@ import { PluginStartContract as AlertsPluginStartContract } from '../../../alert
import {
getPackagePolicyCreateCallback,
getPackagePolicyUpdateCallback,
getPackagePolicyDeleteCallback,
} from '../fleet_integration/fleet_integration';
import { ManifestManager } from './services/artifacts';
import { AppClientFactory } from '../client';
@ -102,6 +103,11 @@ export class EndpointAppContextService {
'packagePolicyUpdate',
getPackagePolicyUpdateCallback(dependencies.logger, dependencies.licenseService)
);
dependencies.registerIngestCallback(
'postPackagePolicyDelete',
getPackagePolicyDeleteCallback(dependencies.exceptionListsClient, this.experimentalFeatures)
);
}
}

View file

@ -6,7 +6,7 @@
*/
import { httpServerMock, loggingSystemMock } from 'src/core/server/mocks';
import { createNewPackagePolicyMock } from '../../../fleet/common/mocks';
import { createNewPackagePolicyMock, deletePackagePolicyMock } from '../../../fleet/common/mocks';
import {
policyFactory,
policyFactoryWithoutPaidFeatures,
@ -14,6 +14,7 @@ import {
import { buildManifestManagerMock } from '../endpoint/services/artifacts/manifest_manager/manifest_manager.mock';
import {
getPackagePolicyCreateCallback,
getPackagePolicyDeleteCallback,
getPackagePolicyUpdateCallback,
} from './fleet_integration';
import { KibanaRequest } from 'kibana/server';
@ -28,6 +29,7 @@ import { EndpointDocGenerator } from '../../common/endpoint/generate_data';
import { ProtectionModes } from '../../common/endpoint/types';
import type { SecuritySolutionRequestHandlerContext } from '../types';
import { getExceptionListClientMock } from '../../../lists/server/services/exception_lists/exception_list_client.mock';
import { getExceptionListSchemaMock } from '../../../lists/common/schemas/response/exception_list_schema.mock';
import { ExceptionListClient } from '../../../lists/server';
import { InternalArtifactCompleteSchema } from '../endpoint/schemas/artifacts';
import { ManifestManager } from '../endpoint/services/artifacts/manifest_manager';
@ -35,6 +37,12 @@ import { getMockArtifacts, toArtifactRecords } from '../endpoint/lib/artifacts/m
import { Manifest } from '../endpoint/lib/artifacts';
import { NewPackagePolicy } from '../../../fleet/common/types/models';
import { ManifestSchema } from '../../common/endpoint/schema/manifest';
import {
allowedExperimentalValues,
ExperimentalFeatures,
} from '../../common/experimental_features';
import { DeletePackagePoliciesResponse } from '../../../fleet/common';
import { ExceptionListSchema } from '@kbn/securitysolution-io-ts-list-types';
describe('ingest_integration tests ', () => {
let endpointAppContextMock: EndpointAppContextServiceStartContract;
@ -282,4 +290,66 @@ describe('ingest_integration tests ', () => {
expect(updatedPolicyConfig.inputs[0]!.config!.policy.value).toEqual(mockPolicy);
});
});
describe('package policy delete callback with trusted apps by policy enabled', () => {
const invokeDeleteCallback = async (
experimentalFeatures?: ExperimentalFeatures
): Promise<void> => {
const callback = getPackagePolicyDeleteCallback(exceptionListClient, experimentalFeatures);
await callback(deletePackagePolicyMock());
};
let removedPolicies: DeletePackagePoliciesResponse;
let policyId: string;
let fakeTA: ExceptionListSchema;
beforeEach(() => {
removedPolicies = deletePackagePolicyMock();
policyId = removedPolicies[0].id;
fakeTA = {
...getExceptionListSchemaMock(),
tags: [`policy:${policyId}`],
};
exceptionListClient.findExceptionListItem = jest
.fn()
.mockResolvedValueOnce({ data: [fakeTA], total: 1 });
exceptionListClient.updateExceptionListItem = jest
.fn()
.mockResolvedValueOnce({ ...fakeTA, tags: [] });
});
it('removes policy from trusted app FF enabled', async () => {
await invokeDeleteCallback({
...allowedExperimentalValues,
trustedAppsByPolicyEnabled: true, // Needs to be enabled, it needs also a test with this disabled.
});
expect(exceptionListClient.findExceptionListItem).toHaveBeenCalledWith({
filter: `exception-list-agnostic.attributes.tags:"policy:${policyId}"`,
listId: 'endpoint_trusted_apps',
namespaceType: 'agnostic',
page: 1,
perPage: 50,
sortField: undefined,
sortOrder: undefined,
});
expect(exceptionListClient.updateExceptionListItem).toHaveBeenCalledWith({
...fakeTA,
namespaceType: fakeTA.namespace_type,
osTypes: fakeTA.os_types,
tags: [],
});
});
it("doesn't remove policy from trusted app FF disabled", async () => {
await invokeDeleteCallback({
...allowedExperimentalValues,
});
expect(exceptionListClient.findExceptionListItem).toHaveBeenCalledTimes(0);
expect(exceptionListClient.updateExceptionListItem).toHaveBeenCalledTimes(0);
});
});
});

View file

@ -11,9 +11,12 @@ import { PluginStartContract as AlertsStartContract } from '../../../alerting/se
import { SecurityPluginStart } from '../../../security/server';
import {
PostPackagePolicyCreateCallback,
PostPackagePolicyDeleteCallback,
PutPackagePolicyUpdateCallback,
} from '../../../fleet/server';
import { NewPackagePolicy, UpdatePackagePolicy } from '../../../fleet/common';
import { NewPolicyData, PolicyConfig } from '../../common/endpoint/types';
import { ManifestManager } from '../endpoint/services';
import { AppClientFactory } from '../client';
@ -22,6 +25,8 @@ import { installPrepackagedRules } from './handlers/install_prepackaged_rules';
import { createPolicyArtifactManifest } from './handlers/create_policy_artifact_manifest';
import { createDefaultPolicy } from './handlers/create_default_policy';
import { validatePolicyAgainstLicense } from './handlers/validate_policy_against_license';
import { removePolicyFromTrustedApps } from './handlers/remove_policy_from_trusted_apps';
import { ExperimentalFeatures } from '../../common/experimental_features';
const isEndpointPackagePolicy = <T extends { package?: { name: string } }>(
packagePolicy: T
@ -126,3 +131,21 @@ export const getPackagePolicyUpdateCallback = (
return newPackagePolicy;
};
};
export const getPackagePolicyDeleteCallback = (
exceptionsClient: ExceptionListClient | undefined,
experimentalFeatures: ExperimentalFeatures | undefined
): PostPackagePolicyDeleteCallback => {
return async (deletePackagePolicy): Promise<void> => {
if (!exceptionsClient) {
return;
}
const policiesToRemove: Array<Promise<void>> = [];
for (const policy of deletePackagePolicy) {
if (isEndpointPackagePolicy(policy) && experimentalFeatures?.trustedAppsByPolicyEnabled) {
policiesToRemove.push(removePolicyFromTrustedApps(exceptionsClient, policy));
}
}
await Promise.all(policiesToRemove);
};
};

View file

@ -0,0 +1,61 @@
/*
* 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 { ENDPOINT_TRUSTED_APPS_LIST_ID } from '@kbn/securitysolution-list-constants';
import { ExceptionListClient } from '../../../../lists/server';
import { PostPackagePolicyDeleteCallback } from '../../../../fleet/server';
/**
* Removes policy from trusted apps
*/
export const removePolicyFromTrustedApps = async (
exceptionsClient: ExceptionListClient,
policy: Parameters<PostPackagePolicyDeleteCallback>[0][0]
) => {
let page = 1;
const findTrustedAppsByPolicy = async (currentPage: number) => {
return exceptionsClient.findExceptionListItem({
listId: ENDPOINT_TRUSTED_APPS_LIST_ID,
filter: `exception-list-agnostic.attributes.tags:"policy:${policy.id}"`,
namespaceType: 'agnostic',
page: currentPage,
perPage: 50,
sortField: undefined,
sortOrder: undefined,
});
};
let findResponse = await findTrustedAppsByPolicy(page);
if (!findResponse) {
return;
}
const trustedApps = findResponse.data;
while (findResponse && (trustedApps.length < findResponse.total || findResponse.data.length)) {
page += 1;
findResponse = await findTrustedAppsByPolicy(page);
if (findResponse) {
trustedApps.push(...findResponse.data);
}
}
const updates = [];
for (const trustedApp of trustedApps) {
updates.push(
exceptionsClient.updateExceptionListItem({
...trustedApp,
itemId: trustedApp.item_id,
namespaceType: trustedApp.namespace_type,
osTypes: trustedApp.os_types,
tags: trustedApp.tags.filter((currentPolicy) => currentPolicy !== `policy:${policy.id}`),
})
);
}
await Promise.all(updates);
};