[UII] Make output and fleet server non-editable for agentless policies (#218905)

## Summary

Resolves https://github.com/elastic/security-team/issues/10971.

This PR makes it so that on Cloud, agentless policies cannot move off of
the default managed Fleet Server host and ES output. This is done by:

- Explicitly writing `fleet_server_host_id` and `data_output_id` fields
to the agentless policy that is created when adding an agentless
integration
- On ECH, these are `fleet-default-fleet-server-host` and
`fleet-default-output` respectively
- On Serverless, these are `default-fleet-server` and
`es-default-output`
- During Fleet setup, agentless policies without these fields set up
correctly will be backfilled to the correct values

### Checklist

- [x] [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
- [x] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
This commit is contained in:
Jen Huang 2025-04-24 16:20:58 -07:00 committed by GitHub
parent fc0845256d
commit cad38d6db3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 325 additions and 11 deletions

View file

@ -657,7 +657,7 @@ export const AgentPolicyAdvancedOptionsContent: React.FunctionComponent<Props> =
isDisabled={disabled}
>
<EuiSuperSelect
disabled={disabled || isManagedPolicy}
disabled={disabled || isManagedOrAgentlessPolicy}
valueOfSelected={dataOutputValueOfSelected}
fullWidth
isLoading={isLoadingOptions}

View file

@ -27,7 +27,9 @@ export function useOutputs(
packageName: string
) {
const licenseService = useLicense();
const canUseOutputPerIntegration = licenseService.hasAtLeast(LICENCE_FOR_OUTPUT_PER_INTEGRATION);
const canUseOutputPerIntegration =
licenseService.hasAtLeast(LICENCE_FOR_OUTPUT_PER_INTEGRATION) &&
!packagePolicy.supports_agentless;
const { data: outputsData, isLoading } = useGetOutputs();
const allowedOutputTypes = getAllowedOutputTypesForPackagePolicy(packagePolicy);
const allowedOutputs = useMemo(() => {

View file

@ -363,6 +363,8 @@ describe('PackagePolicyInputPanel', () => {
isAgentlessDefault: false,
isAgentlessAgentPolicy: jest.fn(),
isAgentlessIntegration: jest.fn(),
isServerless: false,
isCloud: true,
});
});
@ -396,6 +398,8 @@ describe('PackagePolicyInputPanel', () => {
isAgentlessDefault: false,
isAgentlessAgentPolicy: jest.fn(),
isAgentlessIntegration: jest.fn(),
isServerless: false,
isCloud: false,
});
});

View file

@ -21,6 +21,8 @@ jest.mock('../../../../../services');
jest.mock('../../../../../hooks', () => ({
...jest.requireActual('../../../../../hooks'),
sendGetOneAgentPolicy: jest.fn(),
sendGetOneFleetServerHost: jest.fn().mockResolvedValue({}),
sendGetOneOutput: jest.fn().mockResolvedValue({}),
useStartServices: jest.fn(),
useConfig: jest.fn(),
}));
@ -295,10 +297,13 @@ describe('useSetupTechnology', () => {
},
});
(generateNewAgentPolicyWithDefaults as MockFn).mockReturnValue({
name: 'Agentless policy for endpoint-1',
supports_agentless: true,
inactivity_timeout: 3600,
(generateNewAgentPolicyWithDefaults as MockFn).mockImplementation((overrides: any) => {
return {
name: 'Agentless policy for endpoint-1',
supports_agentless: true,
inactivity_timeout: 3600,
...overrides,
};
});
jest.clearAllMocks();
});
@ -728,7 +733,9 @@ describe('useSetupTechnology', () => {
expect(setNewAgentPolicy).toHaveBeenCalledWith({
name: 'Agentless policy for endpoint-1',
supports_agentless: true,
global_data_tags: undefined,
inactivity_timeout: 3600,
monitoring_enabled: ['logs', 'metrics'],
});
});
});
@ -768,9 +775,11 @@ describe('useSetupTechnology', () => {
expect(generateNewAgentPolicyWithDefaults).toHaveBeenCalled();
expect(updatePackagePolicyMock).toHaveBeenCalledWith({ supports_agentless: true });
expect(setNewAgentPolicy).toHaveBeenCalledWith({
inactivity_timeout: 3600,
name: 'Agentless policy for endpoint-1',
supports_agentless: true,
global_data_tags: undefined,
inactivity_timeout: 3600,
monitoring_enabled: ['logs', 'metrics'],
});
rerender({
@ -788,7 +797,9 @@ describe('useSetupTechnology', () => {
expect(result.current.selectedSetupTechnology).toBe(SetupTechnology.AGENTLESS);
expect(setNewAgentPolicy).toHaveBeenCalledWith({
name: 'Agentless policy for endpoint-2',
global_data_tags: undefined,
inactivity_timeout: 3600,
monitoring_enabled: ['logs', 'metrics'],
supports_agentless: true,
});
});
@ -934,6 +945,7 @@ describe('useSetupTechnology', () => {
name: 'Agentless policy for endpoint-1',
supports_agentless: true,
inactivity_timeout: 3600,
monitoring_enabled: ['logs', 'metrics'],
global_data_tags: [
{ name: 'organization', value: 'org' },
{ name: 'division', value: 'div' },
@ -1192,7 +1204,9 @@ describe('useSetupTechnology', () => {
expect(setNewAgentPolicy).toHaveBeenCalledWith({
name: 'Agentless policy for endpoint-1',
supports_agentless: true,
global_data_tags: undefined,
inactivity_timeout: 3600,
monitoring_enabled: ['logs', 'metrics'],
});
expect(setNewAgentPolicy).not.toHaveBeenCalledWith({
global_data_tags: [
@ -1266,7 +1280,9 @@ describe('useSetupTechnology', () => {
expect(setNewAgentPolicy).toHaveBeenCalledWith({
name: 'Agentless policy for endpoint-1',
supports_agentless: true,
global_data_tags: undefined,
inactivity_timeout: 3600,
monitoring_enabled: ['logs', 'metrics'],
});
expect(setNewAgentPolicy).not.toHaveBeenCalledWith({
global_data_tags: [
@ -1311,7 +1327,9 @@ describe('useSetupTechnology', () => {
expect(setNewAgentPolicy).toHaveBeenCalledWith({
name: 'Agentless policy for endpoint-1',
supports_agentless: true,
global_data_tags: undefined,
inactivity_timeout: 3600,
monitoring_enabled: ['logs', 'metrics'],
});
expect(setNewAgentPolicy).not.toHaveBeenCalledWith({
global_data_tags: [

View file

@ -7,7 +7,7 @@
import { useCallback, useRef, useState, useEffect, useMemo } from 'react';
import { useConfig } from '../../../../../hooks';
import { useConfig, sendGetOneFleetServerHost, sendGetOneOutput } from '../../../../../hooks';
import { generateNewAgentPolicyWithDefaults } from '../../../../../../../../common/services/generate_new_agent_policy';
import type {
AgentPolicy,
@ -24,6 +24,10 @@ import {
AGENTLESS_GLOBAL_TAG_NAME_TEAM,
AGENTLESS_AGENT_POLICY_INACTIVITY_TIMEOUT,
AGENTLESS_AGENT_POLICY_MONITORING,
SERVERLESS_DEFAULT_OUTPUT_ID,
DEFAULT_OUTPUT_ID,
SERVERLESS_DEFAULT_FLEET_SERVER_HOST_ID,
DEFAULT_FLEET_SERVER_HOST_ID,
} from '../../../../../../../../common/constants';
import {
isAgentlessIntegration as isAgentlessIntegrationFn,
@ -58,6 +62,8 @@ export const useAgentless = () => {
isAgentlessDefault,
isAgentlessAgentPolicy,
isAgentlessIntegration,
isServerless,
isCloud,
};
};
@ -82,7 +88,7 @@ export function useSetupTechnology({
agentPolicies?: AgentPolicy[];
integrationToEnable?: string;
}) {
const { isAgentlessEnabled, isAgentlessDefault } = useAgentless();
const { isAgentlessEnabled, isAgentlessDefault, isServerless, isCloud } = useAgentless();
// this is a placeholder for the new agent-BASED policy that will be used when the user switches from agentless to agent-based and back
const orginalAgentPolicyRef = useRef<NewAgentPolicy>({ ...newAgentPolicy });
@ -112,6 +118,43 @@ export function useSetupTechnology({
}, [isAgentlessEnabled, isAgentlessDefault, packageInfo, integrationToEnable]);
const agentlessPolicyName = getAgentlessAgentPolicyNameFromPackagePolicyName(packagePolicy.name);
const [agentlessPolicyOutputId, setAgentlessPolicyOutputId] = useState<string | undefined>();
const [agentlessPolicyFleetServerHostId, setAgentlessPolicyFleetServerHostId] = useState<
string | undefined
>();
useEffect(() => {
const fetchOutputId = async () => {
const outputId = isServerless
? SERVERLESS_DEFAULT_OUTPUT_ID
: isCloud
? DEFAULT_OUTPUT_ID
: undefined;
if (outputId) {
const outputData = await sendGetOneOutput(outputId);
setAgentlessPolicyOutputId(outputData.data?.item ? outputId : undefined);
} else {
setAgentlessPolicyOutputId(undefined);
}
};
const fetchFleetServerHostId = async () => {
const hostId = isServerless
? SERVERLESS_DEFAULT_FLEET_SERVER_HOST_ID
: isCloud
? DEFAULT_FLEET_SERVER_HOST_ID
: undefined;
if (hostId) {
const hostData = await sendGetOneFleetServerHost(hostId);
setAgentlessPolicyFleetServerHostId(hostData.data?.item ? hostId : undefined);
} else {
setAgentlessPolicyFleetServerHostId(undefined);
}
};
fetchOutputId();
fetchFleetServerHostId();
}, [isCloud, isServerless]);
const handleSetupTechnologyChange = useCallback(
(setupTechnology: SetupTechnology) => {
@ -145,6 +188,10 @@ export function useSetupTechnology({
inactivity_timeout: AGENTLESS_AGENT_POLICY_INACTIVITY_TIMEOUT,
supports_agentless: true,
monitoring_enabled: AGENTLESS_AGENT_POLICY_MONITORING,
...(agentlessPolicyOutputId ? { data_output_id: agentlessPolicyOutputId } : {}),
...(agentlessPolicyFleetServerHostId
? { fleet_server_host_id: agentlessPolicyFleetServerHostId }
: {}),
}),
name: agentlessPolicyName,
global_data_tags: getGlobaDataTags(packageInfo),

View file

@ -26,6 +26,14 @@ export function useGetFleetServerHosts() {
});
}
export function sendGetOneFleetServerHost(itemId: string) {
return sendRequest({
method: 'get',
path: fleetServerHostsRoutesService.getInfoPath(itemId),
version: API_VERSIONS.public.v1,
});
}
export function sendDeleteFleetServerHost(itemId: string) {
return sendRequest({
method: 'delete',

View file

@ -39,6 +39,14 @@ export function useDefaultOutput() {
return { output, refresh: outputsRequest.resendRequest };
}
export function sendGetOneOutput(outputId: string) {
return sendRequest({
method: 'get',
path: outputRoutesService.getInfoPath(outputId),
version: API_VERSIONS.public.v1,
});
}
export function sendPutOutput(outputId: string, body: PutOutputRequest['body']) {
return sendRequest({
method: 'put',

View file

@ -0,0 +1,77 @@
/*
* 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 { ensureCorrectAgentlessSettingsIds } from './agentless_settings_ids';
import { agentPolicyService } from './agent_policy';
jest.mock('.', () => ({
appContextService: {
getLogger: () => ({
debug: jest.fn(),
}),
getCloud: () => ({
isCloudEnabled: true,
isServerlessEnabled: true,
}),
getInternalUserSOClientWithoutSpaceExtension: () => ({
find: jest.fn().mockImplementation(() => {
return {
saved_objects: [{ id: 'agent_policy_1' }, { id: 'agent_policy_2' }],
};
}),
}),
},
}));
jest.mock('./agent_policy', () => ({
agentPolicyService: {
find: jest.fn(),
update: jest.fn(),
},
getAgentPolicySavedObjectType: jest.fn().mockResolvedValue('ingest-agent-policies'),
}));
jest.mock('./output', () => ({
outputService: {
get: jest.fn().mockResolvedValue({
id: 'es-default-output',
}),
},
}));
jest.mock('./fleet_server_host', () => ({
fleetServerHostService: {
get: jest.fn().mockResolvedValue({
id: 'default-fleet-server',
}),
},
}));
describe('correct agentless policy settings', () => {
it('should correct agentless policy settings', async () => {
await ensureCorrectAgentlessSettingsIds(undefined as any);
expect(agentPolicyService.update).toHaveBeenCalledWith(
expect.anything(),
undefined,
'agent_policy_1',
{
data_output_id: 'es-default-output',
fleet_server_host_id: 'default-fleet-server',
}
);
expect(agentPolicyService.update).toHaveBeenCalledWith(
expect.anything(),
undefined,
'agent_policy_2',
{
data_output_id: 'es-default-output',
fleet_server_host_id: 'default-fleet-server',
}
);
});
});

View file

@ -0,0 +1,140 @@
/*
* 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 type { ElasticsearchClient } from '@kbn/core/server';
import pMap from 'p-map';
import {
MAX_CONCURRENT_AGENT_POLICIES_OPERATIONS,
SO_SEARCH_LIMIT,
DEFAULT_OUTPUT_ID,
SERVERLESS_DEFAULT_OUTPUT_ID,
DEFAULT_FLEET_SERVER_HOST_ID,
SERVERLESS_DEFAULT_FLEET_SERVER_HOST_ID,
} from '../constants';
import type { AgentPolicySOAttributes } from '../types';
import { getAgentPolicySavedObjectType, agentPolicyService } from './agent_policy';
import { fleetServerHostService } from './fleet_server_host';
import { outputService } from './output';
import { appContextService } from '.';
export async function ensureCorrectAgentlessSettingsIds(esClient: ElasticsearchClient) {
const cloudSetup = appContextService.getCloud();
const isCloud = cloudSetup?.isCloudEnabled;
const isServerless = cloudSetup?.isServerlessEnabled;
const correctOutputId = isServerless
? SERVERLESS_DEFAULT_OUTPUT_ID
: isCloud
? DEFAULT_OUTPUT_ID
: undefined;
const correctFleetServerId = isServerless
? SERVERLESS_DEFAULT_FLEET_SERVER_HOST_ID
: isCloud
? DEFAULT_FLEET_SERVER_HOST_ID
: undefined;
let fixOutput = false;
let fixFleetServer = false;
if (!correctOutputId && !correctFleetServerId) {
return;
}
const agentPolicySavedObjectType = await getAgentPolicySavedObjectType();
const internalSoClientWithoutSpaceExtension =
appContextService.getInternalUserSOClientWithoutSpaceExtension();
const agentlessOutputIdsToFix = correctOutputId
? (
await internalSoClientWithoutSpaceExtension.find<AgentPolicySOAttributes>({
type: agentPolicySavedObjectType,
page: 1,
perPage: SO_SEARCH_LIMIT,
filter: `${agentPolicySavedObjectType}.attributes.supports_agentless:true AND NOT ${agentPolicySavedObjectType}.attributes.data_output_id:${correctOutputId}`,
fields: [`id`],
namespaces: ['*'],
})
)?.saved_objects.map((so) => so.id)
: [];
const agentlessFleetServerIdsToFix = correctFleetServerId
? (
await internalSoClientWithoutSpaceExtension.find<AgentPolicySOAttributes>({
type: agentPolicySavedObjectType,
page: 1,
perPage: SO_SEARCH_LIMIT,
filter: `${agentPolicySavedObjectType}.attributes.supports_agentless:true AND NOT ${agentPolicySavedObjectType}.attributes.fleet_server_host_id:${correctFleetServerId}`,
fields: [`id`],
namespaces: ['*'],
})
)?.saved_objects.map((so) => so.id)
: [];
try {
// Check that the output ID exists
if (correctOutputId && agentlessOutputIdsToFix?.length > 0) {
const output = await outputService.get(
internalSoClientWithoutSpaceExtension,
correctOutputId
);
fixOutput = output != null;
}
} catch (e) {
// Silently swallow
}
try {
// Check that the fleet server host ID exists
if (correctFleetServerId && agentlessFleetServerIdsToFix?.length > 0) {
const fleetServerHost = await fleetServerHostService.get(
internalSoClientWithoutSpaceExtension,
correctFleetServerId
);
fixFleetServer = fleetServerHost != null;
}
} catch (e) {
// Silently swallow
}
const allIdsToFix = Array.from(
new Set([
...(fixOutput ? agentlessOutputIdsToFix : []),
...(fixFleetServer ? agentlessFleetServerIdsToFix : []),
])
);
if (allIdsToFix.length === 0) {
return;
}
appContextService
.getLogger()
.debug(
`Fixing output and/or fleet server host IDs on agent policies: ${agentlessOutputIdsToFix}`
);
await pMap(
allIdsToFix,
(agentPolicyId) => {
return agentPolicyService.update(
internalSoClientWithoutSpaceExtension,
esClient,
agentPolicyId,
{
data_output_id: correctOutputId,
fleet_server_host_id: correctFleetServerId,
}
);
},
{
concurrency: MAX_CONCURRENT_AGENT_POLICIES_OPERATIONS,
}
);
}

View file

@ -65,6 +65,7 @@ import {
import { backfillPackagePolicySupportsAgentless } from './backfill_agentless';
import { updateDeprecatedComponentTemplates } from './setup/update_deprecated_component_templates';
import { createOrUpdateFleetSyncedIntegrationsIndex } from './setup/fleet_synced_integrations';
import { ensureCorrectAgentlessSettingsIds } from './agentless_settings_ids';
export interface SetupStatus {
isInitialized: boolean;
@ -311,6 +312,14 @@ async function createSetupSideEffects(
logger.debug('Backfilling package policy supports_agentless field');
await backfillPackagePolicySupportsAgentless(esClient);
let ensureCorrectAgentlessSettingsIdsError;
try {
logger.debug('Fix agentless policy settings');
await ensureCorrectAgentlessSettingsIds(esClient);
} catch (error) {
ensureCorrectAgentlessSettingsIdsError = { error };
}
logger.debug('Update deprecated _source.mode in component templates');
await updateDeprecatedComponentTemplates(esClient);
@ -320,6 +329,7 @@ async function createSetupSideEffects(
const nonFatalErrors = [
...preconfiguredPackagesNonFatalErrors,
...(messageSigningServiceNonFatalError ? [messageSigningServiceNonFatalError] : []),
...(ensureCorrectAgentlessSettingsIdsError ? [ensureCorrectAgentlessSettingsIdsError] : []),
];
if (nonFatalErrors.length > 0) {

View file

@ -8,6 +8,7 @@
import { CA_CERT_PATH, KBN_CERT_PATH, KBN_KEY_PATH } from '@kbn/dev-utils';
import { CLOUD_SECURITY_POSTURE_PACKAGE_VERSION } from './constants';
import { createTestConfig } from '../../config.base';
import { kbnServerArgs as fleetKbnServerArgs } from '../../../api_integration/test_suites/common/fleet/default_setup';
// TODO: Remove the agentless default config once Serverless API is merged and default policy is deleted
export default createTestConfig({
@ -16,12 +17,11 @@ export default createTestConfig({
reportName: 'Serverless Security Cloud Security Agentless Onboarding Functional Tests',
},
kbnServerArgs: [
`--xpack.cloud.serverless.project_id=some_fake_project_id`,
...fleetKbnServerArgs, // Needed for correct serverless default Fleet Server and ES output
`--xpack.fleet.packages.0.name=cloud_security_posture`,
`--xpack.fleet.packages.0.version=${CLOUD_SECURITY_POSTURE_PACKAGE_VERSION}`,
`--xpack.fleet.agentless.enabled=true`,
`--xpack.fleet.agents.fleet_server.hosts=["https://ftr.kibana:8220"]`,
`--xpack.fleet.internal.fleetServerStandalone=true`,
// Agentless Configuration based on Serverless Default policy`,