[Fleet] Add support_agentless property in agent policy schema and preconfiguration (#182709)

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

## Summary
Add a new `support_agentless` property in agent policy and in
preconfiguration; this property is only allowed when the environment has
both `isServerless` set to `true` and `agentless` feature flag enabled,
otherwise policy creation/update will throw error `supports_agentless is
only allowed in serverless environments that support agentless feature`.

No UI change is required for now as this property will be needed as part
of a wider support to agentless policies.

## Testing

### Serverless
- Run serverless env configured for agentless following [this
guide](https://docs.elastic.dev/security-solution/cloud-security/serverless/develop-for-kibana#agentless-local-set-up)
- Make sure to have `agentless` feature flag enabled
- Create an agent policy with `support_agentless`  property:
```
POST kbn:/api/fleet/agent_policies
{
  "name": "New agent policy",
  "namespace": "default",
  "supports_agentless": true
}
```
- Update an existing agent policy with the new property:
```
PUT kbn:/api/fleet/agent_policies/<opolicy_id>
{
  "name": "New agent policy",
  "supports_agentless": true
}
```

- Create a preconfigured agent policy in kibana.dev.yml, and verify it
that it's correct via `GET kbn:/api/fleet/agent_policies`:

```
xpack.fleet.agentPolicies: [
  {
    "name": "Agentless Policy",
    "id": "agentless",
    "is_managed": true,
    "namespace": "default",
    "supports_agentless": true,
  },
]
```

- Note that if `agentless` feature flag is disabled, any of the above
will throw an error.

### Stateful
Spin up a stateful env and verify that all of the previous commands fail
with `400` and above error message.

### Checklist
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Cristina Amico 2024-05-08 10:58:24 +02:00 committed by GitHub
parent d87e04dad2
commit eb5e329382
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 336 additions and 12 deletions

View file

@ -182,7 +182,7 @@ export const HASH_TO_VERSION_MAP = {
'infrastructure-monitoring-log-view|c50526fc6040c5355ed027d34d05b35c': '10.0.0',
'infrastructure-ui-source|3d1b76c39bfb2cc8296b024d73854724': '10.0.0',
'ingest_manager_settings|b91ffb075799c78ffd7dbd51a279c8c9': '10.1.0',
'ingest-agent-policies|0fd93cd11c019b118e93a9157c22057b': '10.1.0',
'ingest-agent-policies|0ab9774bc7728d0c0f37d841570f2872': '10.2.0',
'ingest-download-sources|0b0f6828e59805bd07a650d80817c342': '10.0.0',
'ingest-outputs|b1237f7fdc0967709e75d65d208ace05': '10.6.0',
'ingest-package-policies|ca63c4c5a946704f045803a6b975dbc6': '10.9.0',

View file

@ -510,6 +510,7 @@
"revision",
"schema_version",
"status",
"supports_agentless",
"unenroll_timeout",
"updated_at",
"updated_by"

View file

@ -1711,6 +1711,9 @@
"status": {
"type": "keyword"
},
"supports_agentless": {
"type": "boolean"
},
"unenroll_timeout": {
"type": "integer"
},

View file

@ -108,7 +108,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
"infra-custom-dashboards": "1a5994f2e05bb8a1609825ddbf5012f77c5c67f3",
"infrastructure-monitoring-log-view": "5f86709d3c27aed7a8379153b08ee5d3d90d77f5",
"infrastructure-ui-source": "113182d6895764378dfe7fa9fa027244f3a457c4",
"ingest-agent-policies": "d2ee0bf36a512c2ac744b0def1c822b7880f1f83",
"ingest-agent-policies": "803dc27e106440c41e8f3c3d8ee8bbb0821bcde2",
"ingest-download-sources": "279a68147e62e4d8858c09ad1cf03bd5551ce58d",
"ingest-outputs": "daafff49255ab700e07491376fe89f04fc998b91",
"ingest-package-policies": "d63e091b2b3cf2eecaa46ae2533bdd5214a983fc",

View file

@ -7602,6 +7602,10 @@
"type": "object",
"description": "Advanced settings stored in the agent policy, e.g. agent_limits_go_max_procs",
"nullable": true
},
"supports_agentless": {
"type": "boolean",
"description": "Indicates whether the agent policy supports agentless integrations. Only allowed in a serverless environment."
}
},
"required": [

View file

@ -4894,6 +4894,11 @@ components:
Advanced settings stored in the agent policy, e.g.
agent_limits_go_max_procs
nullable: true
supports_agentless:
type: boolean
description: >-
Indicates whether the agent policy supports agentless integrations.
Only allowed in a serverless environment.
required:
- id
- status

View file

@ -73,6 +73,9 @@ properties:
type: object
description: Advanced settings stored in the agent policy, e.g. agent_limits_go_max_procs
nullable: true
supports_agentless:
type: boolean
description: Indicates whether the agent policy supports agentless integrations. Only allowed in a serverless environment.
required:
- id
- status

View file

@ -41,6 +41,7 @@ export interface NewAgentPolicy {
overrides?: { [key: string]: any } | null;
advanced_settings?: { [key: string]: any } | null;
keep_monitoring_alive?: boolean | null;
supports_agentless?: boolean | null;
}
// SO definition for this type is declared in server/types/interfaces

View file

@ -164,6 +164,7 @@ export const getSavedObjectTypes = (
overrides: { type: 'flattened', index: false },
keep_monitoring_alive: { type: 'boolean' },
advanced_settings: { type: 'flattened', index: false },
supports_agentless: { type: 'boolean' },
},
},
migrations: {
@ -184,6 +185,16 @@ export const getSavedObjectTypes = (
},
],
},
'2': {
changes: [
{
type: 'mappings_addition',
addedMappings: {
supports_agentless: { type: 'boolean' },
},
},
],
},
},
},
[OUTPUT_SAVED_OBJECT_TYPE]: {

View file

@ -17,6 +17,7 @@ import {
PackagePolicyRestrictionRelatedError,
FleetUnauthorizedError,
HostedAgentPolicyRestrictionRelatedError,
AgentPolicyInvalidError,
} from '../errors';
import type {
AgentPolicy,
@ -114,7 +115,7 @@ function getAgentPolicyCreateMock() {
return soClient;
}
let mockedLogger: jest.Mocked<Logger>;
describe('agent policy', () => {
describe('Agent policy', () => {
beforeEach(() => {
mockedLogger = loggerMock.create();
mockedAppContextService.getLogger.mockReturnValue(mockedLogger);
@ -228,6 +229,74 @@ describe('agent policy', () => {
new FleetUnauthorizedError('Tamper protection requires Platinum license')
);
});
it('should throw AgentPolicyInvalidError if support_agentless is defined in stateful', async () => {
jest
.spyOn(appContextService, 'getExperimentalFeatures')
.mockReturnValue({ agentless: false } as any);
jest
.spyOn(appContextService, 'getCloud')
.mockReturnValue({ isServerlessEnabled: false } as any);
const soClient = getAgentPolicyCreateMock();
const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
await expect(
agentPolicyService.create(soClient, esClient, {
name: 'test',
namespace: 'default',
supports_agentless: true,
})
).rejects.toThrowError(
new AgentPolicyInvalidError(
'supports_agentless is only allowed in serverless environments that support the agentless feature'
)
);
});
it('should throw AgentPolicyInvalidError if agentless feature flag is disabled in serverless', async () => {
jest
.spyOn(appContextService, 'getExperimentalFeatures')
.mockReturnValue({ agentless: false } as any);
jest
.spyOn(appContextService, 'getCloud')
.mockReturnValue({ isServerlessEnabled: true } as any);
const soClient = getAgentPolicyCreateMock();
const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
await expect(
agentPolicyService.create(soClient, esClient, {
name: 'test',
namespace: 'default',
supports_agentless: true,
})
).rejects.toThrowError(
new AgentPolicyInvalidError(
'supports_agentless is only allowed in serverless environments that support the agentless feature'
)
);
});
it('should not throw error if support_agentless is set if agentless feature flag is set in serverless', async () => {
jest
.spyOn(appContextService, 'getExperimentalFeatures')
.mockReturnValue({ agentless: true } as any);
jest
.spyOn(appContextService, 'getCloud')
.mockReturnValue({ isServerlessEnabled: true } as any);
const soClient = getAgentPolicyCreateMock();
const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
await expect(
agentPolicyService.create(soClient, esClient, {
name: 'test',
namespace: 'default',
supports_agentless: true,
})
).resolves.not.toThrow();
});
});
// TODO: Add more test coverage to `get` service method
@ -781,6 +850,93 @@ describe('agent policy', () => {
})
).rejects.toThrowError(new Error('Cannot enable Agent Tamper Protection: reason'));
});
it('should throw AgentPolicyInvalidError if support_agentless is defined in stateful', async () => {
jest
.spyOn(appContextService, 'getExperimentalFeatures')
.mockReturnValue({ agentless: false } as any);
jest
.spyOn(appContextService, 'getCloud')
.mockReturnValue({ isServerlessEnabled: false } as any);
const soClient = getAgentPolicyCreateMock();
const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
soClient.get.mockResolvedValue({
attributes: {},
id: 'test-id',
type: 'mocked',
references: [],
});
await expect(
agentPolicyService.update(soClient, esClient, 'test-id', {
name: 'test',
namespace: 'default',
supports_agentless: true,
})
).rejects.toThrowError(
new AgentPolicyInvalidError(
'supports_agentless is only allowed in serverless environments that support the agentless feature'
)
);
});
it('should throw AgentPolicyInvalidError if agentless flag is disabled in serverless', async () => {
jest
.spyOn(appContextService, 'getExperimentalFeatures')
.mockReturnValue({ agentless: false } as any);
jest
.spyOn(appContextService, 'getCloud')
.mockReturnValue({ isServerlessEnabled: true } as any);
const soClient = getAgentPolicyCreateMock();
const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
soClient.get.mockResolvedValue({
attributes: {},
id: 'test-id',
type: 'mocked',
references: [],
});
await expect(
agentPolicyService.update(soClient, esClient, 'test-id', {
name: 'test',
namespace: 'default',
supports_agentless: true,
})
).rejects.toThrowError(
new AgentPolicyInvalidError(
'supports_agentless is only allowed in serverless environments that support the agentless feature'
)
);
});
it('should not throw in serverless if support_agentless is set and agentless feature flag is set', async () => {
jest
.spyOn(appContextService, 'getExperimentalFeatures')
.mockReturnValue({ agentless: true } as any);
jest
.spyOn(appContextService, 'getCloud')
.mockReturnValue({ isServerlessEnabled: true } as any);
const soClient = getAgentPolicyCreateMock();
const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
soClient.get.mockResolvedValue({
attributes: {},
id: 'test-id',
type: 'mocked',
references: [],
});
await expect(
agentPolicyService.update(soClient, esClient, 'test-id', {
name: 'test',
namespace: 'default',
supports_agentless: true,
})
).resolves.not.toThrow();
});
});
describe('deployPolicy', () => {

View file

@ -71,13 +71,12 @@ import type {
FetchAllAgentPoliciesOptions,
FetchAllAgentPolicyIdsOptions,
FleetServerPolicy,
Installation,
Output,
PackageInfo,
} from '../../common/types';
import {
AgentPolicyNameExistsError,
AgentPolicyNotFoundError,
AgentPolicyInvalidError,
FleetError,
FleetUnauthorizedError,
HostedAgentPolicyRestrictionRelatedError,
@ -88,6 +87,8 @@ import type { FullAgentConfigMap } from '../../common/types/models/agent_cm';
import { fullAgentConfigMapToYaml } from '../../common/services/agent_cm_to_yaml';
import { appContextService } from '.';
import { mapAgentPolicySavedObjectToAgentPolicy } from './agent_policies/utils';
import {
@ -102,7 +103,6 @@ import { incrementPackagePolicyCopyName } from './package_policies';
import { outputService } from './output';
import { agentPolicyUpdateEventHandler } from './agent_policy_update';
import { escapeSearchQueryPhrase, normalizeKuery } from './saved_object';
import { appContextService } from './app_context';
import { getFullAgentPolicy, validateOutputForPolicy } from './agent_policies';
import { auditLoggingService } from './audit_logging';
import { licenseService } from './license';
@ -317,6 +317,8 @@ class AgentPolicyService {
);
}
this.checkAgentless(agentPolicy);
await this.requireUniqueName(soClient, agentPolicy);
await validateOutputForPolicy(soClient, agentPolicy);
@ -581,6 +583,7 @@ class AgentPolicyService {
}
}
this.checkTamperProtectionLicense(agentPolicy);
this.checkAgentless(agentPolicy);
await this.checkForValidUninstallToken(agentPolicy, id);
if (agentPolicy?.is_protected && !policyHasEndpointSecurity(existingAgentPolicy)) {
@ -653,6 +656,7 @@ class AgentPolicyService {
'monitoring_output_id',
'download_source_id',
'fleet_server_host_id',
'supports_agentless',
]),
...newAgentPolicyProps,
},
@ -1474,17 +1478,26 @@ class AgentPolicyService {
}
}
}
private checkAgentless(agentPolicy: Partial<NewAgentPolicy>) {
const cloudSetup = appContextService.getCloud();
if (
(!cloudSetup?.isServerlessEnabled ||
!appContextService.getExperimentalFeatures().agentless) &&
agentPolicy?.supports_agentless !== undefined
) {
throw new AgentPolicyInvalidError(
'supports_agentless is only allowed in serverless environments that support the agentless feature'
);
}
}
}
export const agentPolicyService = new AgentPolicyService();
// TODO: remove unused parameters
export async function addPackageToAgentPolicy(
soClient: SavedObjectsClientContract,
esClient: ElasticsearchClient,
packageToInstall: Installation,
agentPolicy: AgentPolicy,
defaultOutput: Output,
packageInfo: PackageInfo,
packagePolicyName?: string,
packagePolicyId?: string | number,

View file

@ -22,6 +22,8 @@ import type { AgentPolicy, NewPackagePolicy, Output, DownloadSource } from '../t
import { AGENT_POLICY_SAVED_OBJECT_TYPE } from '../constants';
import { appContextService } from './app_context';
import * as agentPolicy from './agent_policy';
import {
@ -300,6 +302,10 @@ jest.mock('./app_context', () => ({
generateTokenForPolicyId: jest.fn(),
}),
getExternalCallbacks: jest.fn(),
getCloud: jest.fn(),
getExperimentalFeatures: jest.fn().mockReturnValue({
agentless: false,
}),
},
}));
@ -461,7 +467,7 @@ describe('policy preconfiguration', () => {
);
});
it('should install prelease packages if needed', async () => {
it('should install prerelease packages if needed', async () => {
const soClient = getPutPreconfiguredPackagesMock();
const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
@ -845,6 +851,7 @@ describe('policy preconfiguration', () => {
it('should not create a policy and throw an error if package is not installed for an unknown reason', async () => {
const soClient = getPutPreconfiguredPackagesMock();
const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
const policies: PreconfiguredAgentPolicy[] = [
{
name: 'Test policy',
@ -875,6 +882,113 @@ describe('policy preconfiguration', () => {
);
});
it('should return a non fatal error if support_agentless is defined in stateful', async () => {
const soClient = getPutPreconfiguredPackagesMock();
const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
jest.mocked(appContextService.getExperimentalFeatures).mockReturnValue({
agentless: true,
} as any);
jest
.spyOn(appContextService, 'getCloud')
.mockReturnValue({ isServerlessEnabled: false } as any);
const policies: PreconfiguredAgentPolicy[] = [
{
name: 'Test policy',
namespace: 'default',
id: 'test-id',
supports_agentless: true,
package_policies: [],
},
];
const { nonFatalErrors } = await ensurePreconfiguredPackagesAndPolicies(
soClient,
esClient,
policies,
[{ name: 'CANNOT_MATCH', version: 'x.y.z' }],
mockDefaultOutput,
mockDefaultDownloadService,
DEFAULT_SPACE_ID
);
// @ts-ignore-next-line
expect(nonFatalErrors[0].error.toString()).toEqual(
'FleetError: `supports_agentless` is only allowed in serverless environments that support the agentless feature'
);
});
it('should not return an error if support_agentless is defined in serverless and agentless is enabled', async () => {
const soClient = getPutPreconfiguredPackagesMock();
const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
jest.mocked(appContextService.getExperimentalFeatures).mockReturnValue({
agentless: true,
} as any);
jest
.spyOn(appContextService, 'getCloud')
.mockReturnValue({ isServerlessEnabled: true } as any);
const policies: PreconfiguredAgentPolicy[] = [
{
name: 'Test policy',
namespace: 'default',
id: 'test-id',
supports_agentless: true,
package_policies: [],
},
];
const { policies: resPolicies, nonFatalErrors } =
await ensurePreconfiguredPackagesAndPolicies(
soClient,
esClient,
policies,
[{ name: 'CANNOT_MATCH', version: 'x.y.z' }],
mockDefaultOutput,
mockDefaultDownloadService,
DEFAULT_SPACE_ID
);
expect(nonFatalErrors.length).toBe(0);
expect(resPolicies[0].id).toEqual('test-id');
});
it('should return an error if agentless feature flag is disabled on serverless', async () => {
const soClient = getPutPreconfiguredPackagesMock();
const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
jest.mocked(appContextService.getExperimentalFeatures).mockReturnValue({
agentless: false,
} as any);
jest
.spyOn(appContextService, 'getCloud')
.mockReturnValue({ isServerlessEnabled: true } as any);
const policies: PreconfiguredAgentPolicy[] = [
{
name: 'Test policy',
namespace: 'default',
id: 'test-id',
supports_agentless: true,
package_policies: [],
},
];
const { nonFatalErrors } = await ensurePreconfiguredPackagesAndPolicies(
soClient,
esClient,
policies,
[{ name: 'CANNOT_MATCH', version: 'x.y.z' }],
mockDefaultOutput,
mockDefaultDownloadService,
DEFAULT_SPACE_ID
);
// @ts-ignore-next-line
expect(nonFatalErrors[0].error.toString()).toEqual(
'FleetError: `supports_agentless` is only allowed in serverless environments that support the agentless feature'
);
});
it('should not attempt to recreate or modify an agent policy if its ID is unchanged', async () => {
const soClient = getPutPreconfiguredPackagesMock();
const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;

View file

@ -59,6 +59,7 @@ export async function ensurePreconfiguredPackagesAndPolicies(
spaceId: string
): Promise<PreconfigurationResult> {
const logger = appContextService.getLogger();
const cloudSetup = appContextService.getCloud();
// Validate configured packages to ensure there are no version conflicts
const packageNames = groupBy(packages, (pkg) => pkg.name);
@ -160,6 +161,19 @@ export async function ensurePreconfiguredPackagesAndPolicies(
);
}
if (
(!cloudSetup?.isServerlessEnabled ||
!appContextService.getExperimentalFeatures().agentless) &&
preconfiguredAgentPolicy?.supports_agentless !== undefined
) {
throw new FleetError(
i18n.translate('xpack.fleet.preconfiguration.support_agentless', {
defaultMessage:
'`supports_agentless` is only allowed in serverless environments that support the agentless feature',
})
);
}
const { created, policy } = await agentPolicyService.ensurePreconfiguredAgentPolicy(
soClient,
esClient,
@ -372,9 +386,7 @@ async function addPreconfiguredPolicyPackages(
await addPackageToAgentPolicy(
soClient,
esClient,
installedPackage,
agentPolicy,
defaultOutput,
packageInfo,
name,
id,

View file

@ -83,6 +83,7 @@ export const AgentPolicyBaseSchema = {
)
),
...getSettingsAPISchema('AGENT_POLICY_ADVANCED_SETTINGS'),
supports_agentless: schema.maybe(schema.boolean({ defaultValue: false })),
};
export const NewAgentPolicySchema = schema.object({