diff --git a/x-pack/plugins/fleet/common/constants/agent_policy.ts b/x-pack/plugins/fleet/common/constants/agent_policy.ts index 363607aae2b4..96b6249585bf 100644 --- a/x-pack/plugins/fleet/common/constants/agent_policy.ts +++ b/x-pack/plugins/fleet/common/constants/agent_policy.ts @@ -24,6 +24,7 @@ export const DEFAULT_AGENT_POLICY: Omit< status: agentPolicyStatuses.Active, package_policies: [], is_default: true, + is_managed: false, monitoring_enabled: ['logs', 'metrics'] as Array<'logs' | 'metrics'>, }; diff --git a/x-pack/plugins/fleet/common/types/models/agent_policy.ts b/x-pack/plugins/fleet/common/types/models/agent_policy.ts index 5e86e8e6acb7..5f41b0f70ca7 100644 --- a/x-pack/plugins/fleet/common/types/models/agent_policy.ts +++ b/x-pack/plugins/fleet/common/types/models/agent_policy.ts @@ -17,6 +17,7 @@ export interface NewAgentPolicy { namespace: string; description?: string; is_default?: boolean; + is_managed?: boolean; // Optional when creating a policy monitoring_enabled?: Array>; } @@ -24,6 +25,7 @@ export interface AgentPolicy extends NewAgentPolicy { id: string; status: ValueOf; package_policies: string[] | PackagePolicy[]; + is_managed: boolean; // required for created policy updated_at: string; updated_by: string; revision: number; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.test.tsx index 30588c10178d..b60d3b5eb1f2 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.test.tsx @@ -687,6 +687,7 @@ On Windows, the module was tested with Nginx installed from the Chocolatey repos 'e8a37031-2907-44f6-89d2-98bd493f60dc', ], is_default: true, + is_managed: false, monitoring_enabled: ['logs', 'metrics'], revision: 6, updated_at: '2020-12-09T13:46:31.840Z', @@ -701,6 +702,7 @@ On Windows, the module was tested with Nginx installed from the Chocolatey repos status: 'active', package_policies: ['e8a37031-2907-44f6-89d2-98bd493f60cd'], is_default: false, + is_managed: false, monitoring_enabled: ['logs', 'metrics'], revision: 2, updated_at: '2020-12-09T13:46:31.840Z', diff --git a/x-pack/plugins/fleet/server/errors/index.ts b/x-pack/plugins/fleet/server/errors/index.ts index ee30c01ac8ee..a903de013803 100644 --- a/x-pack/plugins/fleet/server/errors/index.ts +++ b/x-pack/plugins/fleet/server/errors/index.ts @@ -32,3 +32,5 @@ export class PackageCacheError extends IngestManagerError {} export class PackageOperationNotSupportedError extends IngestManagerError {} export class FleetAdminUserInvalidError extends IngestManagerError {} export class ConcurrentInstallOperationError extends IngestManagerError {} +export class AgentReassignmentError extends IngestManagerError {} +export class AgentUnenrollmentError extends IngestManagerError {} diff --git a/x-pack/plugins/fleet/server/routes/agent/unenroll_handler.ts b/x-pack/plugins/fleet/server/routes/agent/unenroll_handler.ts index 0365d9f5a29f..614ccd8a2662 100644 --- a/x-pack/plugins/fleet/server/routes/agent/unenroll_handler.ts +++ b/x-pack/plugins/fleet/server/routes/agent/unenroll_handler.ts @@ -24,7 +24,7 @@ export const postAgentUnenrollHandler: RequestHandler< if (request.body?.force === true) { await AgentService.forceUnenrollAgent(soClient, esClient, request.params.agentId); } else { - await AgentService.unenrollAgent(soClient, request.params.agentId); + await AgentService.unenrollAgent(soClient, esClient, request.params.agentId); } const body: PostAgentUnenrollResponse = {}; diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts index c61dd1b8e4a1..d50db8d9809f 100644 --- a/x-pack/plugins/fleet/server/saved_objects/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/index.ts @@ -32,7 +32,7 @@ import { migrateSettingsToV7100, migrateAgentActionToV7100, } from './migrations/to_v7_10_0'; -import { migrateAgentToV7120 } from './migrations/to_v7_12_0'; +import { migrateAgentToV7120, migrateAgentPolicyToV7120 } from './migrations/to_v7_12_0'; /* * Saved object types and mappings @@ -161,6 +161,7 @@ const getSavedObjectTypes = ( description: { type: 'text' }, namespace: { type: 'keyword' }, is_default: { type: 'boolean' }, + is_managed: { type: 'boolean' }, status: { type: 'keyword' }, package_policies: { type: 'keyword' }, updated_at: { type: 'date' }, @@ -171,6 +172,7 @@ const getSavedObjectTypes = ( }, migrations: { '7.10.0': migrateAgentPolicyToV7100, + '7.12.0': migrateAgentPolicyToV7120, }, }, [ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE]: { diff --git a/x-pack/plugins/fleet/server/saved_objects/migrations/to_v7_12_0.ts b/x-pack/plugins/fleet/server/saved_objects/migrations/to_v7_12_0.ts index 1635f38cd552..49a0d6fc7737 100644 --- a/x-pack/plugins/fleet/server/saved_objects/migrations/to_v7_12_0.ts +++ b/x-pack/plugins/fleet/server/saved_objects/migrations/to_v7_12_0.ts @@ -5,8 +5,8 @@ * 2.0. */ -import { SavedObjectMigrationFn } from 'kibana/server'; -import { Agent } from '../../types'; +import type { SavedObjectMigrationFn } from 'kibana/server'; +import type { Agent, AgentPolicy } from '../../types'; export const migrateAgentToV7120: SavedObjectMigrationFn = ( agentDoc @@ -15,3 +15,14 @@ export const migrateAgentToV7120: SavedObjectMigrationFn, + AgentPolicy +> = (agentPolicyDoc) => { + const isV12 = 'is_managed' in agentPolicyDoc.attributes; + if (!isV12) { + agentPolicyDoc.attributes.is_managed = false; + } + return agentPolicyDoc; +}; diff --git a/x-pack/plugins/fleet/server/services/agent_policy.test.ts b/x-pack/plugins/fleet/server/services/agent_policy.test.ts index b70041e66dcd..800d4f479bfd 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.test.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.test.ts @@ -8,17 +8,16 @@ import { elasticsearchServiceMock, savedObjectsClientMock } from 'src/core/server/mocks'; import { agentPolicyService } from './agent_policy'; import { agentPolicyUpdateEventHandler } from './agent_policy_update'; -import { Output } from '../types'; +import type { AgentPolicy, NewAgentPolicy, Output } from '../types'; function getSavedObjectMock(agentPolicyAttributes: any) { const mock = savedObjectsClientMock.create(); - mock.get.mockImplementation(async (type: string, id: string) => { return { type, id, references: [], - attributes: agentPolicyAttributes, + attributes: agentPolicyAttributes as AgentPolicy, }; }); mock.find.mockImplementation(async (options) => { @@ -69,10 +68,59 @@ function getAgentPolicyUpdateMock() { >; } +function getAgentPolicyCreateMock() { + const soClient = savedObjectsClientMock.create(); + soClient.create.mockImplementation(async (type, attributes) => { + return { + attributes: (attributes as unknown) as NewAgentPolicy, + id: 'mocked', + type: 'mocked', + references: [], + }; + }); + return soClient; +} describe('agent policy', () => { beforeEach(() => { getAgentPolicyUpdateMock().mockClear(); }); + + describe('create', () => { + it('is_managed present and false by default', async () => { + // ignore unrelated unique name constraint + agentPolicyService.requireUniqueName = async () => {}; + const soClient = getAgentPolicyCreateMock(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + + await expect( + agentPolicyService.create(soClient, esClient, { + name: 'No is_managed provided', + namespace: 'default', + }) + ).resolves.toHaveProperty('is_managed', false); + + const [, attributes] = soClient.create.mock.calls[0]; + expect(attributes).toHaveProperty('is_managed', false); + }); + + it('should set is_managed property, if given', async () => { + // ignore unrelated unique name constraint + agentPolicyService.requireUniqueName = async () => {}; + const soClient = getAgentPolicyCreateMock(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + await expect( + agentPolicyService.create(soClient, esClient, { + name: 'is_managed: true provided', + namespace: 'default', + is_managed: true, + }) + ).resolves.toHaveProperty('is_managed', true); + + const [, attributes] = soClient.create.mock.calls[0]; + expect(attributes).toHaveProperty('is_managed', true); + }); + }); + describe('bumpRevision', () => { it('should call agentPolicyUpdateEventHandler with updated event once', async () => { const soClient = getSavedObjectMock({ @@ -208,4 +256,37 @@ describe('agent policy', () => { }); }); }); + + describe('update', () => { + it('should update is_managed property, if given', async () => { + // ignore unrelated unique name constraint + agentPolicyService.requireUniqueName = async () => {}; + const soClient = savedObjectsClientMock.create(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + + soClient.get.mockResolvedValue({ + attributes: {}, + id: 'mocked', + type: 'mocked', + references: [], + }); + await agentPolicyService.update(soClient, esClient, 'mocked', { + name: 'mocked', + namespace: 'default', + is_managed: false, + }); + // soClient.update is called with updated values + let calledWith = soClient.update.mock.calls[0]; + expect(calledWith[2]).toHaveProperty('is_managed', false); + + await agentPolicyService.update(soClient, esClient, 'mocked', { + name: 'is_managed: true provided', + namespace: 'default', + is_managed: true, + }); + // soClient.update is called with updated values + calledWith = soClient.update.mock.calls[1]; + expect(calledWith[2]).toHaveProperty('is_managed', true); + }); + }); }); diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index 4a3319941b57..dfe5c19bc417 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -140,6 +140,7 @@ class AgentPolicyService { SAVED_OBJECT_TYPE, { ...agentPolicy, + is_managed: agentPolicy.is_managed ?? false, revision: 1, updated_at: new Date().toISOString(), updated_by: options?.user?.username || 'system', diff --git a/x-pack/plugins/fleet/server/services/agents/crud.ts b/x-pack/plugins/fleet/server/services/agents/crud.ts index 9382a8bb6164..36506d059059 100644 --- a/x-pack/plugins/fleet/server/services/agents/crud.ts +++ b/x-pack/plugins/fleet/server/services/agents/crud.ts @@ -12,7 +12,7 @@ import { AGENT_SAVED_OBJECT_TYPE } from '../../constants'; import { AgentSOAttributes, Agent, ListWithKuery } from '../../types'; import { escapeSearchQueryPhrase } from '../saved_object'; import { savedObjectToAgent } from './saved_objects'; -import { appContextService } from '../../services'; +import { appContextService, agentPolicyService } from '../../services'; import * as crudServiceSO from './crud_so'; import * as crudServiceFleetServer from './crud_fleet_server'; @@ -86,6 +86,22 @@ export async function getAgents(soClient: SavedObjectsClientContract, agentIds: return agents; } +export async function getAgentPolicyForAgent( + soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, + agentId: string +) { + const agent = await getAgent(soClient, esClient, agentId); + if (!agent.policy_id) { + return; + } + + const agentPolicy = await agentPolicyService.get(soClient, agent.policy_id, false); + if (agentPolicy) { + return agentPolicy; + } +} + export async function getAgentByAccessAPIKeyId( soClient: SavedObjectsClientContract, accessAPIKeyId: string diff --git a/x-pack/plugins/fleet/server/services/agents/reassign.test.ts b/x-pack/plugins/fleet/server/services/agents/reassign.test.ts new file mode 100644 index 000000000000..7338c440483e --- /dev/null +++ b/x-pack/plugins/fleet/server/services/agents/reassign.test.ts @@ -0,0 +1,132 @@ +/* + * 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, savedObjectsClientMock } from 'src/core/server/mocks'; +import type { SavedObject } from 'kibana/server'; +import type { Agent, AgentPolicy } from '../../types'; +import { AgentReassignmentError } from '../../errors'; +import { reassignAgent, reassignAgents } from './reassign'; + +const agentInManagedSO = { + id: 'agent-in-managed-policy', + attributes: { policy_id: 'managed-agent-policy' }, +} as SavedObject; +const agentInManagedSO2 = { + id: 'agent-in-managed-policy2', + attributes: { policy_id: 'managed-agent-policy' }, +} as SavedObject; +const agentInUnmanagedSO = { + id: 'agent-in-unmanaged-policy', + attributes: { policy_id: 'unmanaged-agent-policy' }, +} as SavedObject; +const agentInUnmanagedSO2 = { + id: 'agent-in-unmanaged-policy2', + attributes: { policy_id: 'unmanaged-agent-policy' }, +} as SavedObject; +const unmanagedAgentPolicySO = { + id: 'unmanaged-agent-policy', + attributes: { is_managed: false }, +} as SavedObject; +const managedAgentPolicySO = { + id: 'managed-agent-policy', + attributes: { is_managed: true }, +} as SavedObject; + +describe('reassignAgent (singular)', () => { + it('can reassign from unmanaged policy to unmanaged', async () => { + const soClient = createClientMock(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + await reassignAgent(soClient, esClient, agentInUnmanagedSO.id, agentInUnmanagedSO2.id); + + // calls ES update with correct values + expect(soClient.update).toBeCalledTimes(1); + const calledWith = soClient.update.mock.calls[0]; + expect(calledWith[1]).toBe(agentInUnmanagedSO.id); + expect(calledWith[2]).toHaveProperty('policy_id', agentInUnmanagedSO2.id); + }); + + it('cannot reassign from unmanaged policy to managed', async () => { + const soClient = createClientMock(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + await expect( + reassignAgent( + soClient, + esClient, + agentInUnmanagedSO.id, + agentInManagedSO.attributes.policy_id! + ) + ).rejects.toThrowError(AgentReassignmentError); + + // does not call ES update + expect(soClient.update).toBeCalledTimes(0); + }); + + it('cannot reassign from managed policy', async () => { + const soClient = createClientMock(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + await expect( + reassignAgent(soClient, esClient, agentInManagedSO.id, agentInManagedSO2.id) + ).rejects.toThrowError(AgentReassignmentError); + // does not call ES update + expect(soClient.update).toBeCalledTimes(0); + + await expect( + reassignAgent(soClient, esClient, agentInManagedSO.id, agentInUnmanagedSO.id) + ).rejects.toThrowError(AgentReassignmentError); + // does not call ES update + expect(soClient.update).toBeCalledTimes(0); + }); +}); + +describe('reassignAgents (plural)', () => { + it('agents in managed policies are not updated', async () => { + const soClient = createClientMock(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + const idsToReassign = [agentInUnmanagedSO.id, agentInManagedSO.id, agentInUnmanagedSO.id]; + await reassignAgents(soClient, esClient, { agentIds: idsToReassign }, agentInUnmanagedSO.id); + + // calls ES update with correct values + const calledWith = soClient.bulkUpdate.mock.calls[0][0]; + const expectedResults = [agentInUnmanagedSO.id, agentInUnmanagedSO.id]; + expect(calledWith.length).toBe(expectedResults.length); // only 2 are unmanaged + expect(calledWith.map(({ id }) => id)).toEqual(expectedResults); + }); +}); + +function createClientMock() { + const soClientMock = savedObjectsClientMock.create(); + + // need to mock .create & bulkCreate due to (bulk)createAgentAction(s) in reassignAgent(s) + soClientMock.create.mockResolvedValue(agentInUnmanagedSO); + soClientMock.bulkCreate.mockImplementation(async ([{ type, attributes }]) => { + return { + saved_objects: [await soClientMock.create(type, attributes)], + }; + }); + + soClientMock.get.mockImplementation(async (_, id) => { + switch (id) { + case unmanagedAgentPolicySO.id: + return unmanagedAgentPolicySO; + case managedAgentPolicySO.id: + return managedAgentPolicySO; + case agentInManagedSO.id: + return agentInManagedSO; + case agentInUnmanagedSO.id: + default: + return agentInUnmanagedSO; + } + }); + + soClientMock.bulkGet.mockImplementation(async (options) => { + return { + saved_objects: await Promise.all(options!.map(({ type, id }) => soClientMock.get(type, id))), + }; + }); + + return soClientMock; +} diff --git a/x-pack/plugins/fleet/server/services/agents/reassign.ts b/x-pack/plugins/fleet/server/services/agents/reassign.ts index fbd91c05dfb4..9f4373ab553e 100644 --- a/x-pack/plugins/fleet/server/services/agents/reassign.ts +++ b/x-pack/plugins/fleet/server/services/agents/reassign.ts @@ -5,12 +5,13 @@ * 2.0. */ -import { SavedObjectsClientContract, ElasticsearchClient } from 'kibana/server'; +import type { SavedObjectsClientContract, ElasticsearchClient } from 'kibana/server'; import Boom from '@hapi/boom'; import { AGENT_SAVED_OBJECT_TYPE } from '../../constants'; -import { AgentSOAttributes } from '../../types'; +import type { AgentSOAttributes } from '../../types'; +import { AgentReassignmentError } from '../../errors'; import { agentPolicyService } from '../agent_policy'; -import { getAgents, listAllAgents } from './crud'; +import { getAgentPolicyForAgent, getAgents, listAllAgents } from './crud'; import { createAgentAction, bulkCreateAgentActions } from './actions'; export async function reassignAgent( @@ -19,11 +20,13 @@ export async function reassignAgent( agentId: string, newAgentPolicyId: string ) { - const agentPolicy = await agentPolicyService.get(soClient, newAgentPolicyId); - if (!agentPolicy) { + const newAgentPolicy = await agentPolicyService.get(soClient, newAgentPolicyId); + if (!newAgentPolicy) { throw Boom.notFound(`Agent policy not found: ${newAgentPolicyId}`); } + await reassignAgentIsAllowed(soClient, esClient, agentId, newAgentPolicyId); + await soClient.update(AGENT_SAVED_OBJECT_TYPE, agentId, { policy_id: newAgentPolicyId, policy_revision: null, @@ -36,6 +39,29 @@ export async function reassignAgent( }); } +export async function reassignAgentIsAllowed( + soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, + agentId: string, + newAgentPolicyId: string +) { + const agentPolicy = await getAgentPolicyForAgent(soClient, esClient, agentId); + if (agentPolicy?.is_managed) { + throw new AgentReassignmentError( + `Cannot reassign an agent from managed agent policy ${agentPolicy.id}` + ); + } + + const newAgentPolicy = await agentPolicyService.get(soClient, newAgentPolicyId); + if (newAgentPolicy?.is_managed) { + throw new AgentReassignmentError( + `Cannot reassign an agent to managed agent policy ${newAgentPolicy.id}` + ); + } + + return true; +} + export async function reassignAgents( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, @@ -63,7 +89,15 @@ export async function reassignAgents( showInactive: false, }) ).agents; - const agentsToUpdate = agents.filter((agent) => agent.policy_id !== newAgentPolicyId); + // And which are allowed to unenroll + const settled = await Promise.allSettled( + agents.map((agent) => + reassignAgentIsAllowed(soClient, esClient, agent.id, newAgentPolicyId).then((_) => agent) + ) + ); + const agentsToUpdate = agents.filter( + (agent, index) => settled[index].status === 'fulfilled' && agent.policy_id !== newAgentPolicyId + ); // Update the necessary agents const res = await soClient.bulkUpdate( diff --git a/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts b/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts new file mode 100644 index 000000000000..b8c1b7befb44 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts @@ -0,0 +1,125 @@ +/* + * 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, savedObjectsClientMock } from 'src/core/server/mocks'; +import type { SavedObject } from 'kibana/server'; +import type { Agent, AgentPolicy } from '../../types'; +import { AgentUnenrollmentError } from '../../errors'; +import { unenrollAgent, unenrollAgents } from './unenroll'; + +const agentInManagedSO = { + id: 'agent-in-managed-policy', + attributes: { policy_id: 'managed-agent-policy' }, +} as SavedObject; +const agentInUnmanagedSO = { + id: 'agent-in-unmanaged-policy', + attributes: { policy_id: 'unmanaged-agent-policy' }, +} as SavedObject; +const agentInUnmanagedSO2 = { + id: 'agent-in-unmanaged-policy2', + attributes: { policy_id: 'unmanaged-agent-policy' }, +} as SavedObject; +const unmanagedAgentPolicySO = { + id: 'unmanaged-agent-policy', + attributes: { is_managed: false }, +} as SavedObject; +const managedAgentPolicySO = { + id: 'managed-agent-policy', + attributes: { is_managed: true }, +} as SavedObject; + +describe('unenrollAgent (singular)', () => { + it('can unenroll from unmanaged policy', async () => { + const soClient = createClientMock(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + await unenrollAgent(soClient, esClient, agentInUnmanagedSO.id); + + // calls ES update with correct values + expect(soClient.update).toBeCalledTimes(1); + const calledWith = soClient.update.mock.calls[0]; + expect(calledWith[1]).toBe(agentInUnmanagedSO.id); + expect(calledWith[2]).toHaveProperty('unenrollment_started_at'); + }); + + it('cannot unenroll from managed policy', async () => { + const soClient = createClientMock(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + await expect(unenrollAgent(soClient, esClient, agentInManagedSO.id)).rejects.toThrowError( + AgentUnenrollmentError + ); + // does not call ES update + expect(soClient.update).toBeCalledTimes(0); + }); +}); + +describe('unenrollAgents (plural)', () => { + it('can unenroll from an unmanaged policy', async () => { + const soClient = createClientMock(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + const idsToUnenroll = [agentInUnmanagedSO.id, agentInUnmanagedSO2.id]; + await unenrollAgents(soClient, esClient, { agentIds: idsToUnenroll }); + + // calls ES update with correct values + const calledWith = soClient.bulkUpdate.mock.calls[0][0]; + expect(calledWith.length).toBe(idsToUnenroll.length); + expect(calledWith.map(({ id }) => id)).toEqual(idsToUnenroll); + for (const params of calledWith) { + expect(params.attributes).toHaveProperty('unenrollment_started_at'); + } + }); + it('cannot unenroll from a managed policy', async () => { + const soClient = createClientMock(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + const idsToUnenroll = [agentInUnmanagedSO.id, agentInManagedSO.id, agentInUnmanagedSO2.id]; + await unenrollAgents(soClient, esClient, { agentIds: idsToUnenroll }); + + // calls ES update with correct values + const calledWith = soClient.bulkUpdate.mock.calls[0][0]; + const onlyUnmanaged = [agentInUnmanagedSO.id, agentInUnmanagedSO2.id]; + expect(calledWith.length).toBe(onlyUnmanaged.length); + expect(calledWith.map(({ id }) => id)).toEqual(onlyUnmanaged); + for (const params of calledWith) { + expect(params.attributes).toHaveProperty('unenrollment_started_at'); + } + }); +}); + +function createClientMock() { + const soClientMock = savedObjectsClientMock.create(); + + // need to mock .create & bulkCreate due to (bulk)createAgentAction(s) in unenrollAgent(s) + soClientMock.create.mockResolvedValue(agentInUnmanagedSO); + soClientMock.bulkCreate.mockImplementation(async ([{ type, attributes }]) => { + return { + saved_objects: [await soClientMock.create(type, attributes)], + }; + }); + + soClientMock.get.mockImplementation(async (_, id) => { + switch (id) { + case unmanagedAgentPolicySO.id: + return unmanagedAgentPolicySO; + case managedAgentPolicySO.id: + return managedAgentPolicySO; + case agentInManagedSO.id: + return agentInManagedSO; + case agentInUnmanagedSO2.id: + return agentInUnmanagedSO2; + case agentInUnmanagedSO.id: + default: + return agentInUnmanagedSO; + } + }); + + soClientMock.bulkGet.mockImplementation(async (options) => { + return { + saved_objects: await Promise.all(options!.map(({ type, id }) => soClientMock.get(type, id))), + }; + }); + + return soClientMock; +} diff --git a/x-pack/plugins/fleet/server/services/agents/unenroll.ts b/x-pack/plugins/fleet/server/services/agents/unenroll.ts index a20b742d1425..e2fa83cf32b6 100644 --- a/x-pack/plugins/fleet/server/services/agents/unenroll.ts +++ b/x-pack/plugins/fleet/server/services/agents/unenroll.ts @@ -4,16 +4,36 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server'; -import { AgentSOAttributes } from '../../types'; +import type { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server'; +import type { AgentSOAttributes } from '../../types'; +import { AgentUnenrollmentError } from '../../errors'; import { AGENT_SAVED_OBJECT_TYPE } from '../../constants'; -import { getAgent } from './crud'; import * as APIKeyService from '../api_keys'; import { createAgentAction, bulkCreateAgentActions } from './actions'; -import { getAgents, listAllAgents } from './crud'; +import { getAgent, getAgentPolicyForAgent, getAgents, listAllAgents } from './crud'; + +async function unenrollAgentIsAllowed( + soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, + agentId: string +) { + const agentPolicy = await getAgentPolicyForAgent(soClient, esClient, agentId); + if (agentPolicy?.is_managed) { + throw new AgentUnenrollmentError( + `Cannot unenroll ${agentId} from a managed agent policy ${agentPolicy.id}` + ); + } + + return true; +} + +export async function unenrollAgent( + soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, + agentId: string +) { + await unenrollAgentIsAllowed(soClient, esClient, agentId); -export async function unenrollAgent(soClient: SavedObjectsClientContract, agentId: string) { const now = new Date().toISOString(); await createAgentAction(soClient, { agent_id: agentId, @@ -36,7 +56,6 @@ export async function unenrollAgents( kuery: string; } ) { - // Filter to agents that do not already unenrolled, or unenrolling const agents = 'agentIds' in options ? await getAgents(soClient, options.agentIds) @@ -46,9 +65,19 @@ export async function unenrollAgents( showInactive: false, }) ).agents; - const agentsToUpdate = agents.filter( + + // Filter to agents that are not already unenrolled, or unenrolling + const agentsEnrolled = agents.filter( (agent) => !agent.unenrollment_started_at && !agent.unenrolled_at ); + // And which are allowed to unenroll + const settled = await Promise.allSettled( + agentsEnrolled.map((agent) => + unenrollAgentIsAllowed(soClient, esClient, agent.id).then((_) => agent) + ) + ); + const agentsToUpdate = agentsEnrolled.filter((_, index) => settled[index].status === 'fulfilled'); + const now = new Date().toISOString(); // Create unenroll action for each agent diff --git a/x-pack/plugins/fleet/server/services/agents/update.ts b/x-pack/plugins/fleet/server/services/agents/update.ts index f6b4b4400476..21087be392bc 100644 --- a/x-pack/plugins/fleet/server/services/agents/update.ts +++ b/x-pack/plugins/fleet/server/services/agents/update.ts @@ -29,7 +29,7 @@ export async function unenrollForAgentPolicyId( hasMore = false; } for (const agent of agents) { - await unenrollAgent(soClient, agent.id); + await unenrollAgent(soClient, esClient, agent.id); } } } diff --git a/x-pack/plugins/fleet/server/types/models/agent_policy.ts b/x-pack/plugins/fleet/server/types/models/agent_policy.ts index 209bfb4b7398..5891320c2544 100644 --- a/x-pack/plugins/fleet/server/types/models/agent_policy.ts +++ b/x-pack/plugins/fleet/server/types/models/agent_policy.ts @@ -13,6 +13,7 @@ const AgentPolicyBaseSchema = { name: schema.string({ minLength: 1 }), namespace: NamespaceSchema, description: schema.maybe(schema.string()), + is_managed: schema.maybe(schema.boolean()), monitoring_enabled: schema.maybe( schema.arrayOf( schema.oneOf([schema.literal(dataTypes.Logs), schema.literal(dataTypes.Metrics)]) @@ -27,6 +28,7 @@ export const NewAgentPolicySchema = schema.object({ export const AgentPolicySchema = schema.object({ ...AgentPolicyBaseSchema, id: schema.string(), + is_managed: schema.boolean(), status: schema.oneOf([ schema.literal(agentPolicyStatuses.Active), schema.literal(agentPolicyStatuses.Inactive), diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts index d4bae9d88d26..ba64814cd1da 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -1277,6 +1277,7 @@ export class EndpointDocGenerator { status: agentPolicyStatuses.Active, description: 'Some description', namespace: 'default', + is_managed: false, monitoring_enabled: ['logs', 'metrics'], revision: 2, updated_at: '2020-07-22T16:36:49.196Z', diff --git a/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts b/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts index 0d0749aa8e91..9f016ab044a9 100644 --- a/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts +++ b/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts @@ -26,8 +26,10 @@ export default function ({ getService }: FtrProviderContext) { }); describe('POST /api/fleet/agent_policies', () => { - it('should work with valid values', async () => { - await supertest + it('should work with valid minimum required values', async () => { + const { + body: { item: createdPolicy }, + } = await supertest .post(`/api/fleet/agent_policies`) .set('kbn-xsrf', 'xxxx') .send({ @@ -35,6 +37,28 @@ export default function ({ getService }: FtrProviderContext) { namespace: 'default', }) .expect(200); + + const getRes = await supertest.get(`/api/fleet/agent_policies/${createdPolicy.id}`); + const json = getRes.body; + expect(json.item.is_managed).to.equal(false); + }); + + it('sets given is_managed value', async () => { + const { + body: { item: createdPolicy }, + } = await supertest + .post(`/api/fleet/agent_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'TEST2', + namespace: 'default', + is_managed: true, + }) + .expect(200); + + const getRes = await supertest.get(`/api/fleet/agent_policies/${createdPolicy.id}`); + const json = getRes.body; + expect(json.item.is_managed).to.equal(true); }); it('should return a 400 with an empty namespace', async () => { @@ -108,6 +132,7 @@ export default function ({ getService }: FtrProviderContext) { expect(newPolicy).to.eql({ name: 'Copied policy', description: 'Test', + is_managed: false, namespace: 'default', monitoring_enabled: ['logs', 'metrics'], revision: 1, @@ -161,6 +186,7 @@ export default function ({ getService }: FtrProviderContext) { }); describe('PUT /api/fleet/agent_policies/{agentPolicyId}', () => { + let agentPolicyId: undefined | string; it('should work with valid values', async () => { const { body: { item: originalPolicy }, @@ -173,11 +199,11 @@ export default function ({ getService }: FtrProviderContext) { namespace: 'default', }) .expect(200); - + agentPolicyId = originalPolicy.id; const { body: { item: updatedPolicy }, } = await supertest - .put(`/api/fleet/agent_policies/${originalPolicy.id}`) + .put(`/api/fleet/agent_policies/${agentPolicyId}`) .set('kbn-xsrf', 'xxxx') .send({ name: 'Updated name', @@ -193,12 +219,31 @@ export default function ({ getService }: FtrProviderContext) { name: 'Updated name', description: 'Updated description', namespace: 'default', + is_managed: false, revision: 2, updated_by: 'elastic', package_policies: [], }); }); + it('sets given is_managed value', async () => { + const { + body: { item: createdPolicy }, + } = await supertest + .put(`/api/fleet/agent_policies/${agentPolicyId}`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'TEST2', + namespace: 'default', + is_managed: true, + }) + .expect(200); + + const getRes = await supertest.get(`/api/fleet/agent_policies/${createdPolicy.id}`); + const json = getRes.body; + expect(json.item.is_managed).to.equal(true); + }); + it('should return a 409 if policy already exists with name given', async () => { const sharedBody = { name: 'Initial name', diff --git a/x-pack/test/fleet_api_integration/apis/agents/reassign.ts b/x-pack/test/fleet_api_integration/apis/agents/reassign.ts index e17e779e4217..a31fa862f742 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/reassign.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/reassign.ts @@ -16,10 +16,10 @@ export default function (providerContext: FtrProviderContext) { describe('fleet_reassign_agent', () => { setupFleetAndAgents(providerContext); - before(async () => { + beforeEach(async () => { await esArchiver.loadIfNeeded('fleet/agents'); }); - after(async () => { + afterEach(async () => { await esArchiver.unload('fleet/agents'); }); @@ -31,7 +31,7 @@ export default function (providerContext: FtrProviderContext) { policy_id: 'policy2', }) .expect(200); - const { body } = await supertest.get(`/api/fleet/agents/agent1`).set('kbn-xsrf', 'xxx'); + const { body } = await supertest.get(`/api/fleet/agents/agent1`); expect(body.item.policy_id).to.eql('policy2'); }); @@ -88,5 +88,34 @@ export default function (providerContext: FtrProviderContext) { }) .expect(404); }); + + it('can reassign from unmanaged policy to unmanaged', async () => { + // policy2 is not managed + // reassign succeeds + await supertest + .put(`/api/fleet/agents/agent1/reassign`) + .set('kbn-xsrf', 'xxx') + .send({ + policy_id: 'policy2', + }) + .expect(200); + }); + it('cannot reassign from unmanaged policy to managed', async () => { + // agent1 is enrolled in policy1. set policy1 to managed + await supertest + .put(`/api/fleet/agent_policies/policy1`) + .set('kbn-xsrf', 'xxx') + .send({ name: 'Test policy', namespace: 'default', is_managed: true }) + .expect(200); + + // reassign fails + await supertest + .put(`/api/fleet/agents/agent1/reassign`) + .set('kbn-xsrf', 'xxx') + .send({ + policy_id: 'policy2', + }) + .expect(400); + }); }); } diff --git a/x-pack/test/fleet_api_integration/apis/agents/unenroll.ts b/x-pack/test/fleet_api_integration/apis/agents/unenroll.ts index 3cafc86602d3..85bcce824dd5 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/unenroll.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/unenroll.ts @@ -65,17 +65,28 @@ export default function (providerContext: FtrProviderContext) { await esArchiver.unload('fleet/agents'); }); - it('should allow to unenroll single agent', async () => { + it('/agents/{agent_id}/unenroll should fail for managed policy', async () => { + // set policy to managed await supertest - .post(`/api/fleet/agents/agent1/unenroll`) + .put(`/api/fleet/agent_policies/policy1`) .set('kbn-xsrf', 'xxx') - .send({ - force: true, - }) + .send({ name: 'Test policy', namespace: 'default', is_managed: true }) + .expect(200); + + await supertest.post(`/api/fleet/agents/agent1/unenroll`).set('kbn-xsrf', 'xxx').expect(400); + }); + + it('/agents/{agent_id}/unenroll should allow from unmanaged policy', async () => { + // set policy to unmanaged + await supertest + .put(`/api/fleet/agent_policies/policy1`) + .set('kbn-xsrf', 'xxx') + .send({ name: 'Test policy', namespace: 'default', is_managed: false }) .expect(200); + await supertest.post(`/api/fleet/agents/agent1/unenroll`).set('kbn-xsrf', 'xxx').expect(200); }); - it('should invalidate related API keys', async () => { + it('/agents/{agent_id}/unenroll { force: true } should invalidate related API keys', async () => { await supertest .post(`/api/fleet/agents/agent1/unenroll`) .set('kbn-xsrf', 'xxx') @@ -97,7 +108,44 @@ export default function (providerContext: FtrProviderContext) { expect(outputAPIKeys[0].invalidated).eql(true); }); - it('should allow to unenroll multiple agents by id', async () => { + it('/agents/{agent_id}/bulk_unenroll should not allow unenroll from managed policy', async () => { + // set policy to managed + await supertest + .put(`/api/fleet/agent_policies/policy1`) + .set('kbn-xsrf', 'xxx') + .send({ name: 'Test policy', namespace: 'default', is_managed: true }) + .expect(200); + + // try to unenroll + await supertest + .post(`/api/fleet/agents/bulk_unenroll`) + .set('kbn-xsrf', 'xxx') + .send({ + agents: ['agent2', 'agent3'], + }) + // http request succeeds + .expect(200); + + // but agents are still enrolled + const [agent2data, agent3data] = await Promise.all([ + supertest.get(`/api/fleet/agents/agent2`), + supertest.get(`/api/fleet/agents/agent3`), + ]); + expect(typeof agent2data.body.item.unenrollment_started_at).to.eql('undefined'); + expect(typeof agent2data.body.item.unenrolled_at).to.eql('undefined'); + expect(agent2data.body.item.active).to.eql(true); + expect(typeof agent3data.body.item.unenrollment_started_at).to.be('undefined'); + expect(typeof agent3data.body.item.unenrolled_at).to.be('undefined'); + expect(agent2data.body.item.active).to.eql(true); + }); + + it('/agents/{agent_id}/bulk_unenroll should allow to unenroll multiple agents by id from an unmanaged policy', async () => { + // set policy to unmanaged + await supertest + .put(`/api/fleet/agent_policies/policy1`) + .set('kbn-xsrf', 'xxx') + .send({ name: 'Test policy', namespace: 'default', is_managed: false }) + .expect(200); await supertest .post(`/api/fleet/agents/bulk_unenroll`) .set('kbn-xsrf', 'xxx') @@ -106,8 +154,8 @@ export default function (providerContext: FtrProviderContext) { }) .expect(200); const [agent2data, agent3data] = await Promise.all([ - supertest.get(`/api/fleet/agents/agent2`).set('kbn-xsrf', 'xxx'), - supertest.get(`/api/fleet/agents/agent3`).set('kbn-xsrf', 'xxx'), + supertest.get(`/api/fleet/agents/agent2`), + supertest.get(`/api/fleet/agents/agent3`), ]); expect(typeof agent2data.body.item.unenrollment_started_at).to.eql('string'); expect(agent2data.body.item.active).to.eql(true); @@ -115,7 +163,7 @@ export default function (providerContext: FtrProviderContext) { expect(agent2data.body.item.active).to.eql(true); }); - it('should allow to unenroll multiple agents by kuery', async () => { + it('/agents/{agent_id}/bulk_unenroll should allow to unenroll multiple agents by kuery', async () => { await supertest .post(`/api/fleet/agents/bulk_unenroll`) .set('kbn-xsrf', 'xxx') @@ -125,7 +173,7 @@ export default function (providerContext: FtrProviderContext) { }) .expect(200); - const { body } = await supertest.get(`/api/fleet/agents`).set('kbn-xsrf', 'xxx'); + const { body } = await supertest.get(`/api/fleet/agents`); expect(body.total).to.eql(0); }); });