From 84dc8da61072669973c13659b3d72e7828a4f878 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Mon, 28 Oct 2024 12:00:12 -0400 Subject: [PATCH] [Fleet] Couple agent and package policies spaces (#197487) --- x-pack/plugins/fleet/server/mocks/index.ts | 1 + .../server/services/package_policy.test.ts | 68 ++++++++- .../fleet/server/services/package_policy.ts | 111 +++++++++++--- .../services/spaces/agent_policy.test.ts | 136 ++++++++++++++++++ .../server/services/spaces/agent_policy.ts | 9 +- .../server/services/spaces/package_policy.ts | 41 ++++++ .../change_space_agent_policies.ts | 4 +- 7 files changed, 346 insertions(+), 24 deletions(-) create mode 100644 x-pack/plugins/fleet/server/services/spaces/agent_policy.test.ts create mode 100644 x-pack/plugins/fleet/server/services/spaces/package_policy.ts diff --git a/x-pack/plugins/fleet/server/mocks/index.ts b/x-pack/plugins/fleet/server/mocks/index.ts index ac806c1448a24..f032c1f7bb8c7 100644 --- a/x-pack/plugins/fleet/server/mocks/index.ts +++ b/x-pack/plugins/fleet/server/mocks/index.ts @@ -113,6 +113,7 @@ export const createAppContextStartContractMock = ( experimentalFeatures: { agentTamperProtectionEnabled: true, diagnosticFileUploadEnabled: true, + enableReusableIntegrationPolicies: true, } as ExperimentalFeatures, isProductionMode: true, configInitialValue: { diff --git a/x-pack/plugins/fleet/server/services/package_policy.test.ts b/x-pack/plugins/fleet/server/services/package_policy.test.ts index 30523448e721d..7ea6ae290708b 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.test.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.test.ts @@ -76,9 +76,12 @@ import { sendTelemetryEvents } from './upgrade_sender'; import { auditLoggingService } from './audit_logging'; import { agentPolicyService } from './agent_policy'; import { isSpaceAwarenessEnabled } from './spaces/helpers'; +import { licenseService } from './license'; jest.mock('./spaces/helpers'); +jest.mock('./license'); + const mockedSendTelemetryEvents = sendTelemetryEvents as jest.MockedFunction< typeof sendTelemetryEvents >; @@ -207,7 +210,7 @@ const mockedAuditLoggingService = auditLoggingService as jest.Mocked { +const mockAgentPolicyGet = (spaceIds: string[] = ['default']) => { mockAgentPolicyService.get.mockImplementation( (_soClient: SavedObjectsClientContract, id: string, _force = false, _errorMessage?: string) => { return Promise.resolve({ @@ -220,9 +223,29 @@ const mockAgentPolicyGet = () => { updated_by: 'test', revision: 1, is_protected: false, + space_ids: spaceIds, }); } ); + mockAgentPolicyService.getByIDs.mockImplementation( + // @ts-ignore + (_soClient: SavedObjectsClientContract, ids: string[]) => { + return Promise.resolve( + ids.map((id) => ({ + id, + name: 'Test Agent Policy', + namespace: 'test', + status: 'active', + is_managed: false, + updated_at: new Date().toISOString(), + updated_by: 'test', + revision: 1, + is_protected: false, + space_ids: spaceIds, + })) + ); + } + ); }; describe('Package policy service', () => { @@ -240,6 +263,9 @@ describe('Package policy service', () => { }); describe('create', () => { + beforeEach(() => { + jest.mocked(licenseService.hasAtLeast).mockReturnValue(true); + }); it('should call audit logger', async () => { const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; const soClient = savedObjectsClientMock.create(); @@ -279,6 +305,46 @@ describe('Package policy service', () => { savedObjectType: LEGACY_PACKAGE_POLICY_SAVED_OBJECT_TYPE, }); }); + + it('should not allow to add a reusable integration policies to an agent policies belonging to multiple spaces', async () => { + jest.mocked(isSpaceAwarenessEnabled).mockResolvedValue(true); + + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + const soClient = savedObjectsClientMock.create(); + + soClient.create.mockResolvedValueOnce({ + id: 'test-package-policy', + attributes: {}, + references: [], + type: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + }); + + mockAgentPolicyGet(['test', 'default']); + + await expect( + packagePolicyService.create( + soClient, + esClient, + { + name: 'Test Package Policy', + namespace: 'test', + enabled: true, + policy_id: 'test', + policy_ids: ['test1', 'test2'], + inputs: [], + package: { + name: 'test', + title: 'Test', + version: '0.0.1', + }, + }, + // Skipping unique name verification just means we have to less mocking/setup + { id: 'test-package-policy', skipUniqueNameVerification: true } + ) + ).rejects.toThrowError( + /Reusable integration policies cannot be used with agent policies belonging to multiple spaces./ + ); + }); }); describe('inspect', () => { diff --git a/x-pack/plugins/fleet/server/services/package_policy.ts b/x-pack/plugins/fleet/server/services/package_policy.ts index daa08844d5fbc..bc5bce9eea2a3 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.ts @@ -7,6 +7,7 @@ /* eslint-disable max-classes-per-file */ import { omit, partition, isEqual, cloneDeep, without } from 'lodash'; +import { indexBy } from 'lodash/fp'; import { i18n } from '@kbn/i18n'; import semverLt from 'semver/functions/lt'; import { getFlattenedObject } from '@kbn/std'; @@ -144,6 +145,7 @@ import { validateAgentPolicyOutputForIntegration } from './agent_policies/output import type { PackagePolicyClientFetchAllItemIdsOptions } from './package_policy_service'; import { validatePolicyNamespaceForSpace } from './spaces/policy_namespaces'; import { isSpaceAwarenessEnabled, isSpaceAwarenessMigrationPending } from './spaces/helpers'; +import { updatePackagePolicySpaces } from './spaces/package_policy'; export type InputsOverride = Partial & { vars?: Array; @@ -227,6 +229,7 @@ class PackagePolicyClientImpl implements PackagePolicyClient { context?: RequestHandlerContext, request?: KibanaRequest ): Promise { + const useSpaceAwareness = await isSpaceAwarenessEnabled(); const packagePolicyId = options?.id || uuidv4(); let authorizationHeader = options.authorizationHeader; @@ -274,6 +277,10 @@ class PackagePolicyClientImpl implements PackagePolicyClient { for (const policyId of enrichedPackagePolicy.policy_ids) { const agentPolicy = await agentPolicyService.get(soClient, policyId, true); + if (!agentPolicy) { + throw new AgentPolicyNotFoundError('Agent policy not found'); + } + agentPolicies.push(agentPolicy); // If package policy did not set an output_id, see if the agent policy's output is compatible @@ -285,7 +292,10 @@ class PackagePolicyClientImpl implements PackagePolicyClient { ); } - await validateIsNotHostedPolicy(soClient, policyId, options?.force); + validateIsNotHostedPolicy(agentPolicy, options?.force); + if (useSpaceAwareness) { + validateReusableIntegrationsAndSpaceAwareness(enrichedPackagePolicy, agentPolicies); + } } // trailing whitespace causes issues creating API keys @@ -413,6 +423,21 @@ class PackagePolicyClientImpl implements PackagePolicyClient { { ...options, id: packagePolicyId } ); + for (const agentPolicy of agentPolicies) { + if ( + useSpaceAwareness && + agentPolicy && + agentPolicy.space_ids && + agentPolicy.space_ids.length > 1 + ) { + await updatePackagePolicySpaces({ + packagePolicyId: newSo.id, + currentSpaceId: soClient.getCurrentNamespace() ?? DEFAULT_SPACE_ID, + newSpaceIds: agentPolicy.space_ids, + }); + } + } + if (options?.bumpRevision ?? true) { for (const policyId of enrichedPackagePolicy.policy_ids) { await agentPolicyService.bumpRevision(soClient, esClient, policyId, { @@ -460,6 +485,7 @@ class PackagePolicyClientImpl implements PackagePolicyClient { created: PackagePolicy[]; failed: Array<{ packagePolicy: NewPackagePolicy; error?: Error | SavedObjectError }>; }> { + const useSpaceAwareness = await isSpaceAwarenessEnabled(); const savedObjectType = await getPackagePolicySavedObjectType(); for (const packagePolicy of packagePolicies) { const basePkgInfo = packagePolicy.package @@ -486,8 +512,20 @@ class PackagePolicyClientImpl implements PackagePolicyClient { const agentPolicyIds = new Set(packagePolicies.flatMap((pkgPolicy) => pkgPolicy.policy_ids)); - for (const agentPolicyId of agentPolicyIds) { - await validateIsNotHostedPolicy(soClient, agentPolicyId, options?.force); + const agentPolicies = await agentPolicyService.getByIDs(soClient, [...agentPolicyIds]); + const agentPoliciesIndexById = indexBy('id', agentPolicies); + for (const agentPolicy of agentPolicies) { + validateIsNotHostedPolicy(agentPolicy, options?.force); + } + if (useSpaceAwareness) { + for (const packagePolicy of packagePolicies) { + validateReusableIntegrationsAndSpaceAwareness( + packagePolicy, + packagePolicy.policy_ids + .map((policyId) => agentPoliciesIndexById[policyId]) + .filter((policy) => policy !== undefined) + ); + } } const packageInfos = await getPackageInfoForPackagePolicies(packagePolicies, soClient); @@ -604,6 +642,23 @@ class PackagePolicyClientImpl implements PackagePolicyClient { } }); + if (useSpaceAwareness) { + for (const newSo of newSos) { + // Do not support multpile spaces for reusable integrations + if (newSo.attributes.policy_ids.length > 1) { + continue; + } + const agentPolicy = agentPoliciesIndexById[newSo.attributes.policy_ids[0]]; + if (agentPolicy && agentPolicy.space_ids && agentPolicy.space_ids.length > 1) { + await updatePackagePolicySpaces({ + packagePolicyId: newSo.id, + currentSpaceId: soClient.getCurrentNamespace() ?? DEFAULT_SPACE_ID, + newSpaceIds: agentPolicy.space_ids, + }); + } + } + } + // Assign it to the given agent policy if (options?.bumpRevision ?? true) { @@ -1001,6 +1056,17 @@ class PackagePolicyClientImpl implements PackagePolicyClient { } } + if ((packagePolicyUpdate.policy_ids?.length ?? 0) > 1) { + for (const policyId of packagePolicyUpdate.policy_ids) { + const agentPolicy = await agentPolicyService.get(soClient, policyId, true); + if ((agentPolicy?.space_ids?.length ?? 0) > 1) { + throw new FleetError( + 'Reusable integration policies cannot be used with agent policies belonging to multiple spaces.' + ); + } + } + } + // Handle component template/mappings updates for experimental features, e.g. synthetic source await handleExperimentalDatastreamFeatureOptIn({ soClient, @@ -1391,9 +1457,13 @@ class PackagePolicyClientImpl implements PackagePolicyClient { for (const agentPolicyId of uniqueAgentPolicyIds) { try { - const agentPolicy = await validateIsNotHostedPolicy( - soClient, - agentPolicyId, + const agentPolicy = await agentPolicyService.get(soClient, agentPolicyId); + if (!agentPolicy) { + throw new AgentPolicyNotFoundError('Agent policy not found'); + } + + validateIsNotHostedPolicy( + agentPolicy, options?.force, 'Cannot remove integrations of hosted agent policy' ); @@ -3025,27 +3095,30 @@ export function _validateRestrictedFieldsNotModifiedOrThrow(opts: { } } -async function validateIsNotHostedPolicy( - soClient: SavedObjectsClientContract, - id: string, - force = false, - errorMessage?: string -): Promise { - const agentPolicy = await agentPolicyService.get(soClient, id, false); - - if (!agentPolicy) { - throw new AgentPolicyNotFoundError('Agent policy not found'); +function validateReusableIntegrationsAndSpaceAwareness( + packagePolicy: Pick, + agentPolicies: AgentPolicy[] +) { + if ((packagePolicy.policy_ids.length ?? 0) <= 1) { + return; } + for (const agentPolicy of agentPolicies) { + if ((agentPolicy?.space_ids?.length ?? 0) > 1) { + throw new FleetError( + 'Reusable integration policies cannot be used with agent policies belonging to multiple spaces.' + ); + } + } +} +function validateIsNotHostedPolicy(agentPolicy: AgentPolicy, force = false, errorMessage?: string) { const isManagedPolicyWithoutServerlessSupport = agentPolicy.is_managed && !force; if (isManagedPolicyWithoutServerlessSupport) { throw new HostedAgentPolicyRestrictionRelatedError( - errorMessage ?? `Cannot update integrations of hosted agent policy ${id}` + errorMessage ?? `Cannot update integrations of hosted agent policy ${agentPolicy.id}` ); } - - return agentPolicy; } export function sendUpdatePackagePolicyTelemetryEvent( diff --git a/x-pack/plugins/fleet/server/services/spaces/agent_policy.test.ts b/x-pack/plugins/fleet/server/services/spaces/agent_policy.test.ts new file mode 100644 index 0000000000000..6c2c8e4d6fa50 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/spaces/agent_policy.test.ts @@ -0,0 +1,136 @@ +/* + * 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 { createAppContextStartContractMock } from '../../mocks'; +import { agentPolicyService } from '../agent_policy'; +import { appContextService } from '../app_context'; +import { packagePolicyService } from '../package_policy'; + +import { updateAgentPolicySpaces } from './agent_policy'; +import { isSpaceAwarenessEnabled } from './helpers'; + +jest.mock('./helpers'); +jest.mock('../agent_policy'); +jest.mock('../package_policy'); + +describe('updateAgentPolicySpaces', () => { + beforeEach(() => { + jest.mocked(isSpaceAwarenessEnabled).mockResolvedValue(true); + jest.mocked(agentPolicyService.get).mockResolvedValue({ + id: 'policy1', + space_ids: ['default'], + } as any); + jest.mocked(packagePolicyService.findAllForAgentPolicy).mockResolvedValue([ + { + id: 'package-policy-1', + policy_ids: ['policy1'], + }, + { + id: 'package-policy-2', + policy_ids: ['policy1'], + }, + ] as any); + appContextService.start(createAppContextStartContractMock()); + + jest + .mocked(appContextService.getInternalUserSOClientWithoutSpaceExtension()) + .updateObjectsSpaces.mockResolvedValue({ objects: [] }); + }); + + it('does nothings if agent policy already in correct space', async () => { + await updateAgentPolicySpaces({ + agentPolicyId: 'policy1', + currentSpaceId: 'default', + newSpaceIds: ['default'], + authorizedSpaces: ['default'], + }); + expect( + appContextService.getInternalUserSOClientWithoutSpaceExtension().updateObjectsSpaces + ).not.toBeCalled(); + }); + + it('does nothing if feature flag is not enabled', async () => { + jest.mocked(isSpaceAwarenessEnabled).mockResolvedValue(false); + await updateAgentPolicySpaces({ + agentPolicyId: 'policy1', + currentSpaceId: 'default', + newSpaceIds: ['test'], + authorizedSpaces: ['test', 'default'], + }); + + expect( + appContextService.getInternalUserSOClientWithoutSpaceExtension().updateObjectsSpaces + ).not.toBeCalled(); + }); + + it('allow to change spaces', async () => { + await updateAgentPolicySpaces({ + agentPolicyId: 'policy1', + currentSpaceId: 'default', + newSpaceIds: ['test'], + authorizedSpaces: ['test', 'default'], + }); + + expect( + appContextService.getInternalUserSOClientWithoutSpaceExtension().updateObjectsSpaces + ).toBeCalledWith( + [ + { id: 'policy1', type: 'fleet-agent-policies' }, + { id: 'package-policy-1', type: 'fleet-package-policies' }, + { id: 'package-policy-2', type: 'fleet-package-policies' }, + ], + ['test'], + ['default'], + { namespace: 'default', refresh: 'wait_for' } + ); + }); + + it('throw when trying to change space to a policy with reusable package policies', async () => { + jest.mocked(packagePolicyService.findAllForAgentPolicy).mockResolvedValue([ + { + id: 'package-policy-1', + policy_ids: ['policy1'], + }, + { + id: 'package-policy-2', + policy_ids: ['policy1', 'policy2'], + }, + ] as any); + await expect( + updateAgentPolicySpaces({ + agentPolicyId: 'policy1', + currentSpaceId: 'default', + newSpaceIds: ['test'], + authorizedSpaces: ['test', 'default'], + }) + ).rejects.toThrowError( + /Agent policies using reusable integration policies cannot be moved to a different space./ + ); + }); + + it('throw when trying to add a space with missing permissions', async () => { + await expect( + updateAgentPolicySpaces({ + agentPolicyId: 'policy1', + currentSpaceId: 'default', + newSpaceIds: ['default', 'test'], + authorizedSpaces: ['default'], + }) + ).rejects.toThrowError(/Not enough permissions to create policies in space test/); + }); + + it('throw when trying to remove a space with missing permissions', async () => { + await expect( + updateAgentPolicySpaces({ + agentPolicyId: 'policy1', + currentSpaceId: 'default', + newSpaceIds: ['test'], + authorizedSpaces: ['test'], + }) + ).rejects.toThrowError(/Not enough permissions to remove policies from space default/); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/spaces/agent_policy.ts b/x-pack/plugins/fleet/server/services/spaces/agent_policy.ts index f488a89297265..905f7980af994 100644 --- a/x-pack/plugins/fleet/server/services/spaces/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/spaces/agent_policy.ts @@ -54,6 +54,11 @@ export async function updateAgentPolicySpaces({ return; } + if (existingPackagePolicies.some((packagePolicy) => packagePolicy.policy_ids.length > 1)) { + throw new FleetError( + 'Agent policies using reusable integration policies cannot be moved to a different space.' + ); + } const spacesToAdd = newSpaceIds.filter( (spaceId) => !existingPolicy?.space_ids?.includes(spaceId) ?? true ); @@ -63,13 +68,13 @@ export async function updateAgentPolicySpaces({ // Privileges check for (const spaceId of spacesToAdd) { if (!authorizedSpaces.includes(spaceId)) { - throw new FleetError(`No enough permissions to create policies in space ${spaceId}`); + throw new FleetError(`Not enough permissions to create policies in space ${spaceId}`); } } for (const spaceId of spacesToRemove) { if (!authorizedSpaces.includes(spaceId)) { - throw new FleetError(`No enough permissions to remove policies from space ${spaceId}`); + throw new FleetError(`Not enough permissions to remove policies from space ${spaceId}`); } } diff --git a/x-pack/plugins/fleet/server/services/spaces/package_policy.ts b/x-pack/plugins/fleet/server/services/spaces/package_policy.ts new file mode 100644 index 0000000000000..3abf796061a07 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/spaces/package_policy.ts @@ -0,0 +1,41 @@ +/* + * 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 { PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '../../../common/constants'; + +import { appContextService } from '../app_context'; + +export async function updatePackagePolicySpaces({ + packagePolicyId, + currentSpaceId, + newSpaceIds, +}: { + packagePolicyId: string; + currentSpaceId: string; + newSpaceIds: string[]; +}) { + const soClientWithoutSpaceExtension = + appContextService.getInternalUserSOClientWithoutSpaceExtension(); + + const results = await soClientWithoutSpaceExtension.updateObjectsSpaces( + [ + { + id: packagePolicyId, + type: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + }, + ], + newSpaceIds, + [], + { refresh: 'wait_for', namespace: currentSpaceId } + ); + + for (const soRes of results.objects) { + if (soRes.error) { + throw soRes.error; + } + } +} diff --git a/x-pack/test/fleet_api_integration/apis/space_awareness/change_space_agent_policies.ts b/x-pack/test/fleet_api_integration/apis/space_awareness/change_space_agent_policies.ts index 5fed20f71e664..7a803fd4f66db 100644 --- a/x-pack/test/fleet_api_integration/apis/space_awareness/change_space_agent_policies.ts +++ b/x-pack/test/fleet_api_integration/apis/space_awareness/change_space_agent_policies.ts @@ -165,7 +165,7 @@ export default function (providerContext: FtrProviderContext) { description: 'tata', space_ids: ['default', TEST_SPACE_1], }), - /400 Bad Request No enough permissions to create policies in space test1/ + /400 Bad Request Not enough permissions to create policies in space test1/ ); }); @@ -190,7 +190,7 @@ export default function (providerContext: FtrProviderContext) { description: 'tata', space_ids: ['default'], }), - /400 Bad Request No enough permissions to remove policies from space test1/ + /400 Bad Request Not enough permissions to remove policies from space test1/ ); }); });