[Fleet] Couple agent and package policies spaces (#197487)

This commit is contained in:
Nicolas Chaulet 2024-10-28 12:00:12 -04:00 committed by GitHub
parent 8fc7df26a5
commit 84dc8da610
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 346 additions and 24 deletions

View file

@ -113,6 +113,7 @@ export const createAppContextStartContractMock = (
experimentalFeatures: {
agentTamperProtectionEnabled: true,
diagnosticFileUploadEnabled: true,
enableReusableIntegrationPolicies: true,
} as ExperimentalFeatures,
isProductionMode: true,
configInitialValue: {

View file

@ -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<typeof audi
type CombinedExternalCallback = PutPackagePolicyUpdateCallback | PostPackagePolicyCreateCallback;
const mockAgentPolicyGet = () => {
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', () => {

View file

@ -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<NewPackagePolicyInput> & {
vars?: Array<NewPackagePolicyInput['vars'] & { name: string }>;
@ -227,6 +229,7 @@ class PackagePolicyClientImpl implements PackagePolicyClient {
context?: RequestHandlerContext,
request?: KibanaRequest
): Promise<PackagePolicy> {
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<AgentPolicy> {
const agentPolicy = await agentPolicyService.get(soClient, id, false);
if (!agentPolicy) {
throw new AgentPolicyNotFoundError('Agent policy not found');
function validateReusableIntegrationsAndSpaceAwareness(
packagePolicy: Pick<NewPackagePolicy, 'policy_ids'>,
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(

View file

@ -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/);
});
});

View file

@ -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}`);
}
}

View file

@ -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;
}
}
}

View file

@ -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/
);
});
});