[Fleet] When deleting an agent policy, only delete integration policies if no other agent policies use it (#186601)

Closes https://github.com/elastic/kibana/issues/182222

## Summary
- If `enableReusableIntegrationPolicies` is enabled, the
`agentPolicyService.delete` method finds the integration policies that
are shared with other agent policies and doesn't delete them.
-  Updated the UI so that the modal shows an info box to the user:

![Screenshot 2024-06-24 at 10 20
53](6db8f225-bfc7-47cf-a49c-adc0e0b794ac)


### Checklist

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Cristina Amico 2024-06-24 11:53:39 +02:00 committed by GitHub
parent 5d6b0f8423
commit 8bd7124ca1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 310 additions and 105 deletions

View file

@ -143,6 +143,7 @@ export const AgentPolicyActionMenu = memo<{
<AgentPolicyDeleteProvider
hasFleetServer={policyHasFleetServer(agentPolicy as AgentPolicy)}
key="deletePolicy"
packagePolicies={agentPolicy.package_policies}
>
{(deleteAgentPolicyPrompt) => (
<EuiContextMenuItem

View file

@ -5,14 +5,15 @@
* 2.0.
*/
import React, { Fragment, useRef, useState } from 'react';
import { EuiConfirmModal, EuiCallOut } from '@elastic/eui';
import React, { Fragment, useCallback, useMemo, useRef, useState } from 'react';
import { EuiConfirmModal, EuiCallOut, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { useHistory } from 'react-router-dom';
import { SO_SEARCH_LIMIT } from '../../../../../constants';
import { ExperimentalFeaturesService } from '../../../services';
import {
useStartServices,
@ -22,9 +23,12 @@ import {
sendGetAgents,
} from '../../../hooks';
import type { PackagePolicy } from '../../../types';
interface Props {
children: (deleteAgentPolicy: DeleteAgentPolicy) => React.ReactElement;
hasFleetServer: boolean;
packagePolicies?: PackagePolicy[];
}
export type DeleteAgentPolicy = (agentPolicy: string, onSuccess?: OnSuccessCallback) => void;
@ -34,6 +38,7 @@ type OnSuccessCallback = (agentPolicyDeleted: string) => void;
export const AgentPolicyDeleteProvider: React.FunctionComponent<Props> = ({
children,
hasFleetServer,
packagePolicies,
}) => {
const { notifications } = useStartServices();
const {
@ -48,6 +53,7 @@ export const AgentPolicyDeleteProvider: React.FunctionComponent<Props> = ({
const { getPath } = useLink();
const history = useHistory();
const deleteAgentPolicyMutation = useDeleteAgentPolicyMutation();
const { enableReusableIntegrationPolicies } = ExperimentalFeaturesService.get();
const deleteAgentPolicyPrompt: DeleteAgentPolicy = (
agentPolicyToDelete,
@ -106,20 +112,31 @@ export const AgentPolicyDeleteProvider: React.FunctionComponent<Props> = ({
history.push(getPath('policies_list'));
};
const fetchAgentsCount = async (agentPolicyToCheck: string) => {
if (!isFleetEnabled || isLoadingAgentsCount) {
return;
const fetchAgentsCount = useCallback(
async (agentPolicyToCheck: string) => {
if (!isFleetEnabled || isLoadingAgentsCount) {
return;
}
setIsLoadingAgentsCount(true);
// filtering out the unenrolled agents assigned to this policy
const agents = await sendGetAgents({
showInactive: true,
kuery: `policy_id:"${agentPolicyToCheck}" and not status: unenrolled`,
perPage: SO_SEARCH_LIMIT,
});
setAgentsCount(agents.data?.total ?? 0);
setIsLoadingAgentsCount(false);
},
[isFleetEnabled, isLoadingAgentsCount]
);
const packagePoliciesWithMultiplePolicies = useMemo(() => {
// Find if there are package policies that have multiple agent policies
if (packagePolicies && enableReusableIntegrationPolicies) {
return packagePolicies.some((policy) => policy?.policy_ids.length > 1);
}
setIsLoadingAgentsCount(true);
// filtering out the unenrolled agents assigned to this policy
const agents = await sendGetAgents({
showInactive: true,
kuery: `policy_id:"${agentPolicyToCheck}" and not status: unenrolled`,
perPage: SO_SEARCH_LIMIT,
});
setAgentsCount(agents.data?.total ?? 0);
setIsLoadingAgentsCount(false);
};
return false;
}, [enableReusableIntegrationPolicies, packagePolicies]);
const renderModal = () => {
if (!isModalOpen) {
@ -158,6 +175,21 @@ export const AgentPolicyDeleteProvider: React.FunctionComponent<Props> = ({
buttonColor="danger"
confirmButtonDisabled={isLoading || isLoadingAgentsCount || !!agentsCount}
>
{packagePoliciesWithMultiplePolicies && (
<>
<EuiCallOut
color="primary"
iconType="iInCircle"
title={
<FormattedMessage
id="xpack.fleet.deleteAgentPolicy.confirmModal.warningSharedIntegrationPolicies"
defaultMessage="Fleet has detected that this policy contains integration policies shared by multiple agent policies. These integration policies won't be deleted."
/>
}
/>
<EuiSpacer size="m" />
</>
)}
{isLoadingAgentsCount ? (
<FormattedMessage
id="xpack.fleet.deleteAgentPolicy.confirmModal.loadingAgentsCountMessage"

View file

@ -426,99 +426,255 @@ describe('Agent policy', () => {
let soClient: ReturnType<typeof savedObjectsClientMock.create>;
let esClient: ReturnType<typeof elasticsearchServiceMock.createClusterClient>['asInternalUser'];
beforeEach(() => {
soClient = getSavedObjectMock({ revision: 1, package_policies: ['package-1'] });
mockedPackagePolicyService.findAllForAgentPolicy.mockReturnValue([
{
id: 'package-1',
},
] as any);
esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
describe('with enableReusableIntegrationPolicies disabled', () => {
beforeEach(() => {
soClient = getSavedObjectMock({ revision: 1, package_policies: ['package-1'] });
mockedPackagePolicyService.create.mockReset();
esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
(getAgentsByKuery as jest.Mock).mockResolvedValue({
agents: [],
total: 0,
page: 1,
perPage: 10,
(getAgentsByKuery as jest.Mock).mockResolvedValue({
agents: [],
total: 0,
page: 1,
perPage: 10,
});
mockedPackagePolicyService.delete.mockResolvedValue([
{
id: 'package-1',
} as any,
]);
jest
.spyOn(appContextService, 'getExperimentalFeatures')
.mockReturnValue({ enableReusableIntegrationPolicies: false } as any);
});
mockedPackagePolicyService.delete.mockResolvedValue([
{
id: 'package-1',
} as any,
]);
});
it('should throw error for agent policy which has managed package policy', async () => {
mockedPackagePolicyService.findAllForAgentPolicy.mockReturnValue([
{
id: 'package-1',
is_managed: true,
},
] as any);
try {
await agentPolicyService.delete(soClient, esClient, 'mocked');
} catch (e) {
expect(e.message).toEqual(
new PackagePolicyRestrictionRelatedError(
`Cannot delete agent policy mocked that contains managed package policies`
).message
);
}
});
it('should allow delete with force for agent policy which has managed package policy', async () => {
mockedPackagePolicyService.findAllForAgentPolicy.mockReturnValue([
{
id: 'package-1',
is_managed: true,
},
] as any);
const response = await agentPolicyService.delete(soClient, esClient, 'mocked', {
force: true,
});
expect(response.id).toEqual('mocked');
});
it('should call audit logger', async () => {
await agentPolicyService.delete(soClient, esClient, 'mocked');
expect(mockedAuditLoggingService.writeCustomSoAuditLog).toHaveBeenCalledWith({
action: 'delete',
id: 'mocked',
savedObjectType: AGENT_POLICY_SAVED_OBJECT_TYPE,
});
});
it('should throw error if active agents are assigned to the policy', async () => {
(getAgentsByKuery as jest.Mock).mockResolvedValue({
agents: [],
total: 2,
page: 1,
perPage: 10,
});
await expect(agentPolicyService.delete(soClient, esClient, 'mocked')).rejects.toThrowError(
'Cannot delete an agent policy that is assigned to any active or inactive agents'
);
});
it('should delete .fleet-policies entries on agent policy delete', async () => {
esClient.deleteByQuery.mockResolvedValueOnce({
deleted: 2,
});
await agentPolicyService.delete(soClient, esClient, 'mocked');
expect(esClient.deleteByQuery).toHaveBeenCalledWith(
expect.objectContaining({
index: AGENT_POLICY_INDEX,
query: {
term: {
policy_id: 'mocked',
},
it('should throw error for agent policy which has managed package policy', async () => {
mockedPackagePolicyService.findAllForAgentPolicy.mockReturnValue([
{
id: 'package-1',
is_managed: true,
},
})
);
] as any);
try {
await agentPolicyService.delete(soClient, esClient, 'mocked');
} catch (e) {
expect(e.message).toEqual(
new PackagePolicyRestrictionRelatedError(
`Cannot delete agent policy mocked that contains managed package policies`
).message
);
}
});
it('should allow delete with force for agent policy which has managed package policy', async () => {
mockedPackagePolicyService.findAllForAgentPolicy.mockReturnValue([
{
id: 'package-1',
is_managed: true,
},
] as any);
const response = await agentPolicyService.delete(soClient, esClient, 'mocked', {
force: true,
});
expect(response.id).toEqual('mocked');
});
it('should call audit logger', async () => {
mockedPackagePolicyService.findAllForAgentPolicy.mockReturnValue([
{
id: 'package-1',
},
] as any);
await agentPolicyService.delete(soClient, esClient, 'mocked');
expect(mockedAuditLoggingService.writeCustomSoAuditLog).toHaveBeenCalledWith({
action: 'delete',
id: 'mocked',
savedObjectType: AGENT_POLICY_SAVED_OBJECT_TYPE,
});
});
it('should throw error if active agents are assigned to the policy', async () => {
(getAgentsByKuery as jest.Mock).mockResolvedValue({
agents: [],
total: 2,
page: 1,
perPage: 10,
});
await expect(agentPolicyService.delete(soClient, esClient, 'mocked')).rejects.toThrowError(
'Cannot delete an agent policy that is assigned to any active or inactive agents'
);
});
it('should delete .fleet-policies entries on agent policy delete', async () => {
mockedPackagePolicyService.findAllForAgentPolicy.mockReturnValue([
{
id: 'package-1',
},
] as any);
esClient.deleteByQuery.mockResolvedValueOnce({
deleted: 2,
});
await agentPolicyService.delete(soClient, esClient, 'mocked');
expect(esClient.deleteByQuery).toHaveBeenCalledWith(
expect.objectContaining({
index: AGENT_POLICY_INDEX,
query: {
term: {
policy_id: 'mocked',
},
},
})
);
});
it('should delete all integration polices', async () => {
mockedPackagePolicyService.findAllForAgentPolicy.mockReturnValue([
{
id: 'package-1',
policy_id: ['policy_1'],
policy_ids: ['policy_1', 'int_policy_2'],
},
{
id: 'package-2',
policy_id: ['policy_1'],
policy_ids: ['policy_1'],
},
{
id: 'package-3',
},
] as any);
await agentPolicyService.delete(soClient, esClient, 'mocked');
expect(mockedPackagePolicyService.delete).toBeCalledWith(
expect.anything(),
expect.anything(),
['package-1', 'package-2', 'package-3'],
expect.anything()
);
});
});
describe('with enableReusableIntegrationPolicies enabled', () => {
beforeEach(() => {
soClient = getSavedObjectMock({ revision: 1, package_policies: ['package-1'] });
mockedPackagePolicyService.findAllForAgentPolicy.mockReturnValue([
{
id: 'package-1',
},
] as any);
esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
(getAgentsByKuery as jest.Mock).mockResolvedValue({
agents: [],
total: 0,
page: 1,
perPage: 10,
});
mockedPackagePolicyService.create.mockReset();
jest
.spyOn(appContextService, 'getExperimentalFeatures')
.mockReturnValue({ enableReusableIntegrationPolicies: true } as any);
});
it('should throw error for agent policy which has managed package policy', async () => {
mockedPackagePolicyService.findAllForAgentPolicy.mockReturnValue([
{
id: 'package-1',
is_managed: true,
},
] as any);
try {
await agentPolicyService.delete(soClient, esClient, 'mocked');
} catch (e) {
expect(e.message).toEqual(
new PackagePolicyRestrictionRelatedError(
`Cannot delete agent policy mocked that contains managed package policies`
).message
);
}
});
it('should allow delete with force for agent policy which has managed package policy', async () => {
mockedPackagePolicyService.findAllForAgentPolicy.mockReturnValue([
{
id: 'package-1',
is_managed: true,
},
] as any);
const response = await agentPolicyService.delete(soClient, esClient, 'mocked', {
force: true,
});
expect(response.id).toEqual('mocked');
});
it('should call audit logger', async () => {
await agentPolicyService.delete(soClient, esClient, 'mocked');
expect(mockedAuditLoggingService.writeCustomSoAuditLog).toHaveBeenCalledWith({
action: 'delete',
id: 'mocked',
savedObjectType: AGENT_POLICY_SAVED_OBJECT_TYPE,
});
});
it('should throw error if active agents are assigned to the policy', async () => {
(getAgentsByKuery as jest.Mock).mockResolvedValue({
agents: [],
total: 2,
page: 1,
perPage: 10,
});
await expect(agentPolicyService.delete(soClient, esClient, 'mocked')).rejects.toThrowError(
'Cannot delete an agent policy that is assigned to any active or inactive agents'
);
});
it('should delete .fleet-policies entries on agent policy delete', async () => {
esClient.deleteByQuery.mockResolvedValueOnce({
deleted: 2,
});
await agentPolicyService.delete(soClient, esClient, 'mocked');
expect(esClient.deleteByQuery).toHaveBeenCalledWith(
expect.objectContaining({
index: AGENT_POLICY_INDEX,
query: {
term: {
policy_id: 'mocked',
},
},
})
);
});
it('should only delete package polices that are not shared with other agent policies', async () => {
mockedPackagePolicyService.findAllForAgentPolicy.mockReturnValue([
{
id: 'package-1',
policy_id: ['policy_1'],
policy_ids: ['policy_1', 'int_policy_2'],
},
{
id: 'package-2',
policy_id: ['policy_1'],
policy_ids: ['policy_1'],
},
{
id: 'package-3',
},
] as any);
await agentPolicyService.delete(soClient, esClient, 'mocked');
expect(mockedPackagePolicyService.delete).toBeCalledWith(
expect.anything(),
expect.anything(),
['package-2', 'package-3'],
expect.anything()
);
});
});
});

View file

@ -1008,16 +1008,22 @@ class AgentPolicyService {
`Cannot delete agent policy ${id} that contains managed package policies`
);
}
const packagePoliciesToDelete = this.packagePoliciesWithoutMultiplePolicies(packagePolicies);
await packagePolicyService.delete(
soClient,
esClient,
packagePolicies.map((p) => p.id),
packagePoliciesToDelete.map((p) => p.id),
{
force: options?.force,
skipUnassignFromAgentPolicies: true,
}
);
logger.debug(
`Deleted package policies with ids ${packagePoliciesToDelete
.map((policy) => policy.id)
.join(', ')}`
);
}
if (agentPolicy.is_preconfigured && !options?.force) {
@ -1550,6 +1556,16 @@ class AgentPolicyService {
);
}
}
private packagePoliciesWithoutMultiplePolicies(packagePolicies: PackagePolicy[]) {
// Find package policies that don't have multiple agent policies and mark them for deletion
if (appContextService.getExperimentalFeatures().enableReusableIntegrationPolicies) {
return packagePolicies.filter(
(policy) => !policy?.policy_ids || policy?.policy_ids.length <= 1
);
}
return packagePolicies;
}
}
export const agentPolicyService = new AgentPolicyService();