[Fleet] Add warning if need root integrations trying to be used with unprivileged agents (#183283)

## Summary

Closes https://github.com/elastic/ingest-dev/issues/3252

## Add integration
Added warning to Add integration when the integration requires root
privilege and the selected existing agent policy has unprivileged agents
enrolled.

To verify:
- enroll an agent with docker (it has unprivileged: true)
- try to add an integration that requires root e.g. auditd_manager
- verify that when trying to save the integration, the warning callout
is part of the confirm deploy modal

<img width="807" alt="image"
src="420da729-a4f4-4861-9767-001699629397">

## Add agent flyout
Added warning to Add agent flyout when an unprivileged agent is detected
in combination with an agent policy that has integrations requiring root

To verify:
- add an integration to an agent policy that requires root e.g.
auditd_manager
- open Add agent flyout, verify that the warning callout is visible

<img width="1273" alt="image"
src="e4ae1d73-358b-4d3c-9ca0-27e88bc734a6">

### Open question:
- Do we want to show the warning on `Add agent flyout` only for newly
enrolled agents (in the last 10 mins like we query enrolled agents), or
any unprivileged agents that are enrolled to this policy?
- Decision: No longer applicable as we decided to not show a count here

### 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

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Julia Bardi 2024-05-21 12:45:00 +02:00 committed by GitHub
parent 130bf7b9c5
commit ad03dfb1f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 738 additions and 40 deletions

View file

@ -185,7 +185,7 @@ export const HASH_TO_VERSION_MAP = {
'ingest-agent-policies|0fd93cd11c019b118e93a9157c22057b': '10.1.0',
'ingest-download-sources|0b0f6828e59805bd07a650d80817c342': '10.0.0',
'ingest-outputs|b1237f7fdc0967709e75d65d208ace05': '10.6.0',
'ingest-package-policies|a1a074bad36e68d54f98d2158d60f879': '10.0.0',
'ingest-package-policies|aef7977b81f7930f23cbfd8711ba272e': '10.9.0',
'inventory-view|3d1b76c39bfb2cc8296b024d73854724': '10.0.0',
'kql-telemetry|3d1b76c39bfb2cc8296b024d73854724': '10.0.0',
'legacy-url-alias|0750774cf16475f88f2361e99cc5c8f0': '8.2.0',

View file

@ -602,6 +602,7 @@
"overrides",
"package",
"package.name",
"package.requires_root",
"package.title",
"package.version",
"policy_id",

View file

@ -2017,6 +2017,9 @@
"name": {
"type": "keyword"
},
"requires_root": {
"type": "boolean"
},
"title": {
"type": "keyword"
},

View file

@ -112,7 +112,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
"ingest-agent-policies": "803dc27e106440c41e8f3c3d8ee8bbb0821bcde2",
"ingest-download-sources": "279a68147e62e4d8858c09ad1cf03bd5551ce58d",
"ingest-outputs": "daafff49255ab700e07491376fe89f04fc998b91",
"ingest-package-policies": "e6da7d0ee2996241ade23b3a7811fe5d3e449cb2",
"ingest-package-policies": "44c682a6bf23993c665f0a60a427f3c120a0a10d",
"ingest_manager_settings": "91445219e7115ff0c45d1dabd5d614a80b421797",
"inventory-view": "b8683c8e352a286b4aca1ab21003115a4800af83",
"kql-telemetry": "93c1d16c1a0dfca9c8842062cf5ef8f62ae401ad",

View file

@ -7446,6 +7446,9 @@
},
"title": {
"type": "string"
},
"requires_root": {
"type": "boolean"
}
},
"required": [

View file

@ -4776,6 +4776,8 @@ components:
type: string
title:
type: string
requires_root:
type: boolean
required:
- name
- version

View file

@ -51,7 +51,7 @@ properties:
agents:
type: number
unprivileged_agents:
type: number
type: number
agent_features:
type: array
items:

View file

@ -13,6 +13,8 @@ properties:
type: string
title:
type: string
requires_root:
type: boolean
required:
- name
- version

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { isRootPrivilegesRequired } from './package_helpers';
import { getRootIntegrations, isRootPrivilegesRequired } from './package_helpers';
describe('isRootPrivilegesRequired', () => {
it('should return true if root privileges is required at root level', () => {
@ -38,3 +38,44 @@ describe('isRootPrivilegesRequired', () => {
expect(res).toBe(false);
});
});
describe('getRootIntegrations', () => {
it('should return packages that require root', () => {
const res = getRootIntegrations([
{
package: {
requires_root: true,
name: 'auditd_manager',
title: 'Auditd Manager',
},
} as any,
{
package: {
requires_root: false,
name: 'system',
title: 'System',
},
} as any,
{
package: {
name: 'test',
title: 'Test',
},
} as any,
{
package: {
requires_root: true,
name: 'auditd_manager',
title: 'Auditd Manager',
},
} as any,
{} as any,
]);
expect(res).toEqual([{ name: 'auditd_manager', title: 'Auditd Manager' }]);
});
it('should return empty array if no packages require root', () => {
const res = getRootIntegrations([]);
expect(res).toEqual([]);
});
});

View file

@ -5,7 +5,9 @@
* 2.0.
*/
import type { PackageInfo } from '../types';
import { uniqBy } from 'lodash';
import type { PackageInfo, PackagePolicy } from '../types';
/**
* Return true if a package need Elastic Agent to be run as root/administrator
@ -16,3 +18,14 @@ export function isRootPrivilegesRequired(packageInfo: PackageInfo) {
packageInfo.data_streams?.some((d) => d.agent?.privileges?.root)
);
}
export function getRootIntegrations(
packagePolicies: PackagePolicy[]
): Array<{ name: string; title: string }> {
return uniqBy(
packagePolicies
.map((policy) => policy.package)
.filter((pkg) => (pkg && pkg.requires_root) || false),
(pkg) => pkg!.name
).map((pkg) => ({ name: pkg!.name, title: pkg!.title }));
}

View file

@ -13,6 +13,7 @@ export interface PackagePolicyPackage {
title: string;
version: string;
experimental_data_stream_features?: ExperimentalDataStreamFeature[];
requires_root?: boolean;
}
export interface PackagePolicyConfigRecordEntry {

View file

@ -11,13 +11,23 @@ import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import type { AgentPolicy } from '../../../types';
import { UnprivilegedAgentsCallout } from '../create_package_policy_page/single_page_layout/confirm_modal';
export const ConfirmDeployAgentPolicyModal: React.FunctionComponent<{
onConfirm: () => void;
onCancel: () => void;
agentCount: number;
agentPolicy: AgentPolicy;
}> = ({ onConfirm, onCancel, agentCount, agentPolicy }) => {
showUnprivilegedAgentsCallout?: boolean;
unprivilegedAgentsCount?: number;
}> = ({
onConfirm,
onCancel,
agentCount,
agentPolicy,
showUnprivilegedAgentsCallout = false,
unprivilegedAgentsCount = 0,
}) => {
return (
<EuiConfirmModal
title={
@ -64,6 +74,15 @@ export const ConfirmDeployAgentPolicyModal: React.FunctionComponent<{
/>
</div>
</EuiCallOut>
{showUnprivilegedAgentsCallout && (
<>
<EuiSpacer size="m" />
<UnprivilegedAgentsCallout
agentPolicyName={agentPolicy.name}
unprivilegedAgentsCount={unprivilegedAgentsCount}
/>
</>
)}
<EuiSpacer size="l" />
<FormattedMessage
id="xpack.fleet.agentPolicy.confirmModalDescription"

View file

@ -18,7 +18,7 @@ export const InstallElasticAgentPageStep: React.FC<MultiPageStepLayoutProps> = (
const [localIsManaged, setLocalIsManaged] = useState(props.isManaged);
const [useLocalState, setUseLocalState] = useState(false);
const enrolledAgentIds = usePollingAgentCount(props.agentPolicy?.id || '', {
const { enrolledAgentIds } = usePollingAgentCount(props.agentPolicy?.id || '', {
noLowerTimeLimit: true,
pollImmediately: true,
});

View file

@ -21,6 +21,8 @@ import { ManualInstructions } from '../../../../../../../../../components/enroll
import { KubernetesManifestApplyStep } from '../../../../../../../../../components/agent_enrollment_flyout/steps/run_k8s_apply_command_step';
import { getRootIntegrations } from '../../../../../../../../../../common/services';
import type { InstallAgentPageProps } from './types';
export const InstallElasticAgentManagedPageStep: React.FC<InstallAgentPageProps> = (props) => {
@ -80,6 +82,7 @@ export const InstallElasticAgentManagedPageStep: React.FC<InstallAgentPageProps>
fullCopyButton: true,
fleetServerHost,
onCopy: () => setCommandCopied(true),
rootIntegrations: getRootIntegrations(agentPolicy?.package_policies ?? []),
}),
];

View file

@ -12,6 +12,7 @@ import { safeDump } from 'js-yaml';
import type { FullAgentPolicy } from '../../../../../../../../../../common/types/models/agent_policy';
import { API_VERSIONS } from '../../../../../../../../../../common/constants';
import { getRootIntegrations } from '../../../../../../../../../../common/services';
import {
AgentStandaloneBottomBar,
StandaloneModeWarningCallout,
@ -112,6 +113,7 @@ export const InstallElasticAgentStandalonePageStep: React.FC<InstallAgentPagePro
isComplete: yaml && commandCopied,
fullCopyButton: true,
onCopy: () => setCommandCopied(true),
rootIntegrations: getRootIntegrations(agentPolicy?.package_policies ?? []),
}),
];

View file

@ -0,0 +1,81 @@
/*
* 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 React from 'react';
import { EuiCallOut, EuiConfirmModal } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
export interface UnprivilegedConfirmModalProps {
onConfirm: () => void;
onCancel: () => void;
agentPolicyName: string;
unprivilegedAgentsCount: number;
}
export const UnprivilegedConfirmModal: React.FC<UnprivilegedConfirmModalProps> = ({
onConfirm,
onCancel,
agentPolicyName,
unprivilegedAgentsCount,
}: UnprivilegedConfirmModalProps) => {
return (
<EuiConfirmModal
title={
<FormattedMessage
id="xpack.fleet.addIntegration.confirmModalTitle"
defaultMessage="Confirm add integration"
/>
}
onCancel={onCancel}
onConfirm={onConfirm}
cancelButtonText={
<FormattedMessage
id="xpack.fleet.addIntegration.confirmModal.cancelButtonLabel"
defaultMessage="Cancel"
/>
}
confirmButtonText={
<FormattedMessage
id="xpack.fleet.addIntegration.confirmModal.confirmButtonLabel"
defaultMessage="Add integration"
/>
}
buttonColor="warning"
>
<UnprivilegedAgentsCallout
unprivilegedAgentsCount={unprivilegedAgentsCount}
agentPolicyName={agentPolicyName}
/>
</EuiConfirmModal>
);
};
export const UnprivilegedAgentsCallout: React.FC<{
agentPolicyName: string;
unprivilegedAgentsCount: number;
}> = ({ agentPolicyName, unprivilegedAgentsCount }) => {
return (
<EuiCallOut
color="warning"
iconType="warning"
title={i18n.translate('xpack.fleet.addIntegration.confirmModal.unprivilegedAgentsTitle', {
defaultMessage: 'Unprivileged agents enrolled to the selected policy',
})}
data-test-subj="unprivilegedAgentsCallout"
>
<FormattedMessage
id="xpack.fleet.addIntegration.confirmModal.unprivilegedAgentsMessage"
defaultMessage="This integration requires Elastic Agents to have root privileges. There {unprivilegedAgentsCount, plural, one {is # agent} other {are # agents}} running in an unprivileged mode using {agentPolicyName}. To ensure that all data required by the integration can be collected, re-enroll the {unprivilegedAgentsCount, plural, one {agent} other {agents}} using an account with root privileges."
values={{
unprivilegedAgentsCount,
agentPolicyName,
}}
/>
</EuiCallOut>
);
};

View file

@ -31,7 +31,10 @@ import {
PACKAGE_POLICY_SAVED_OBJECT_TYPE,
SO_SEARCH_LIMIT,
} from '../../../../../../../../common';
import { getMaxPackageName } from '../../../../../../../../common/services';
import {
getMaxPackageName,
isRootPrivilegesRequired,
} from '../../../../../../../../common/services';
import { useConfirmForceInstall } from '../../../../../../integrations/hooks';
import { validatePackagePolicy, validationHasErrors } from '../../services';
import type { PackagePolicyValidationResults } from '../../services';
@ -266,6 +269,16 @@ export function useOnSubmit({
setFormState('CONFIRM');
return;
}
if (
packageInfo &&
isRootPrivilegesRequired(packageInfo) &&
(agentPolicy?.unprivileged_agents ?? 0) > 0 &&
formState !== 'CONFIRM' &&
formState !== 'CONFIRM_UNPRIVILEGED'
) {
setFormState('CONFIRM_UNPRIVILEGED');
return;
}
let createdPolicy = overrideCreatedAgentPolicy;
if (selectedPolicyTab === SelectedPolicyTab.NEW && !overrideCreatedAgentPolicy) {
try {

View file

@ -37,14 +37,28 @@ jest.mock('../../../../hooks', () => {
sendGetAgentStatus: jest.fn().mockResolvedValue({ data: { results: { total: 0 } } }),
useGetAgentPolicies: jest.fn().mockReturnValue({
data: {
items: [{ id: 'agent-policy-1', name: 'Agent policy 1', namespace: 'default' }],
items: [
{
id: 'agent-policy-1',
name: 'Agent policy 1',
namespace: 'default',
unprivileged_agents: 1,
},
],
},
error: undefined,
isLoading: false,
resendRequest: jest.fn(),
} as any),
sendGetOneAgentPolicy: jest.fn().mockResolvedValue({
data: { item: { id: 'agent-policy-1', name: 'Agent policy 1', namespace: 'default' } },
data: {
item: {
id: 'agent-policy-1',
name: 'Agent policy 1',
namespace: 'default',
unprivileged_agents: 1,
},
},
}),
useGetPackageInfoByKeyQuery: jest.fn(),
sendGetSettings: jest.fn().mockResolvedValue({
@ -129,16 +143,9 @@ describe('when on the package policy create page', () => {
/>
</Route>
));
let mockPackageInfo: any;
beforeEach(() => {
testRenderer = createFleetTestRendererMock();
mockApiCalls(testRenderer.startServices.http);
testRenderer.mountHistory.push(createPageUrlPath);
jest.mocked(useStartServices().application.navigateToApp).mockReset();
mockPackageInfo = {
function getMockPackageInfo(requiresRoot?: boolean) {
return {
data: {
item: {
name: 'nginx',
@ -193,12 +200,25 @@ describe('when on the package policy create page', () => {
latestVersion: '1.3.0',
keepPoliciesUpToDate: false,
status: 'not_installed',
agent: {
privileges: {
root: requiresRoot,
},
},
},
},
isLoading: false,
};
}
(useGetPackageInfoByKeyQuery as jest.Mock).mockReturnValue(mockPackageInfo);
beforeEach(() => {
testRenderer = createFleetTestRendererMock();
mockApiCalls(testRenderer.startServices.http);
testRenderer.mountHistory.push(createPageUrlPath);
jest.mocked(useStartServices().application.navigateToApp).mockReset();
(useGetPackageInfoByKeyQuery as jest.Mock).mockReturnValue(getMockPackageInfo());
});
describe('and Route state is provided via Fleet HashRouter', () => {
@ -280,6 +300,73 @@ describe('when on the package policy create page', () => {
vars: undefined,
};
test('should show unprivileged warning modal on submit if conditions match', async () => {
(useGetPackageInfoByKeyQuery as jest.Mock).mockReturnValue(getMockPackageInfo(true));
await act(async () => {
render('agent-policy-1');
});
let saveBtn: HTMLElement;
await waitFor(() => {
saveBtn = renderResult.getByText(/Save and continue/).closest('button')!;
expect(saveBtn).not.toBeDisabled();
});
await act(async () => {
fireEvent.click(saveBtn);
});
await waitFor(() => {
expect(
renderResult.getByText('Unprivileged agents enrolled to the selected policy')
).toBeInTheDocument();
expect(renderResult.getByTestId('unprivilegedAgentsCallout').textContent).toContain(
'This integration requires Elastic Agents to have root privileges. There is 1 agent running in an unprivileged mode using Agent policy 1. To ensure that all data required by the integration can be collected, re-enroll the agent using an account with root privileges.'
);
});
await waitFor(() => {
saveBtn = renderResult.getByText(/Add integration/).closest('button')!;
});
await act(async () => {
fireEvent.click(saveBtn);
});
expect(sendCreatePackagePolicy as jest.MockedFunction<any>).toHaveBeenCalledTimes(1);
});
test('should show unprivileged warning and agents modal on submit if conditions match', async () => {
(useGetPackageInfoByKeyQuery as jest.Mock).mockReturnValue(getMockPackageInfo(true));
(sendGetAgentStatus as jest.MockedFunction<any>).mockResolvedValueOnce({
data: { results: { total: 1 } },
});
await act(async () => {
render('agent-policy-1');
});
await act(async () => {
fireEvent.click(renderResult.getByText(/Save and continue/).closest('button')!);
});
await waitFor(() => {
expect(renderResult.getByText('This action will update 1 agent')).toBeInTheDocument();
expect(
renderResult.getByText('Unprivileged agents enrolled to the selected policy')
).toBeInTheDocument();
expect(renderResult.getByTestId('unprivilegedAgentsCallout').textContent).toContain(
'This integration requires Elastic Agents to have root privileges. There is 1 agent running in an unprivileged mode using Agent policy 1. To ensure that all data required by the integration can be collected, re-enroll the agent using an account with root privileges.'
);
});
await act(async () => {
fireEvent.click(renderResult.getAllByText(/Save and deploy changes/)[1].closest('button')!);
});
expect(sendCreatePackagePolicy as jest.MockedFunction<any>).toHaveBeenCalled();
});
test('should create package policy on submit when query param agent policy id is set', async () => {
await act(async () => {
render('agent-policy-1');
@ -384,10 +471,10 @@ describe('when on the package policy create page', () => {
test('should create agent policy without sys monitoring when new hosts is selected for system integration', async () => {
(useGetPackageInfoByKeyQuery as jest.Mock).mockReturnValue({
...mockPackageInfo,
...getMockPackageInfo(),
data: {
item: {
...mockPackageInfo.data!.item,
...getMockPackageInfo().data!.item,
name: 'system',
},
},
@ -472,7 +559,7 @@ describe('when on the package policy create page', () => {
expect(renderResult.getByText(/Save and continue/).closest('button')!).toBeDisabled();
});
test('should not show modal if agent policy has agents', async () => {
test('should show modal if agent policy has agents', async () => {
(sendGetAgentStatus as jest.MockedFunction<any>).mockResolvedValueOnce({
data: { results: { total: 1 } },
});

View file

@ -75,6 +75,7 @@ import { useDevToolsRequest, useOnSubmit, useSetupTechnology } from './hooks';
import { PostInstallCloudFormationModal } from './components/cloud_security_posture/post_install_cloud_formation_modal';
import { PostInstallGoogleCloudShellModal } from './components/cloud_security_posture/post_install_google_cloud_shell_modal';
import { PostInstallAzureArmTemplateModal } from './components/cloud_security_posture/post_install_azure_arm_template_modal';
import { UnprivilegedConfirmModal } from './confirm_modal';
const StepsWithLessPadding = styled(EuiSteps)`
.euiStep__content {
@ -436,6 +437,7 @@ export const CreatePackagePolicySinglePage: CreatePackagePolicyParams = ({
/>
);
}
return (
<CreatePackagePolicySinglePageLayout {...layoutProps} data-test-subj="createPackagePolicy">
<EuiErrorBoundary>
@ -445,8 +447,22 @@ export const CreatePackagePolicySinglePage: CreatePackagePolicyParams = ({
agentPolicy={agentPolicy}
onConfirm={onSubmit}
onCancel={() => setFormState('VALID')}
showUnprivilegedAgentsCallout={Boolean(
packageInfo &&
isRootPrivilegesRequired(packageInfo) &&
(agentPolicy?.unprivileged_agents ?? 0) > 0
)}
unprivilegedAgentsCount={agentPolicy?.unprivileged_agents ?? 0}
/>
)}
{formState === 'CONFIRM_UNPRIVILEGED' && agentPolicy ? (
<UnprivilegedConfirmModal
onCancel={() => setFormState('VALID')}
onConfirm={onSubmit}
unprivilegedAgentsCount={agentPolicy?.unprivileged_agents ?? 0}
agentPolicyName={agentPolicy?.name ?? ''}
/>
) : null}
{formState === 'SUBMITTED_NO_AGENTS' &&
agentPolicy &&
packageInfo &&

View file

@ -19,6 +19,7 @@ export type PackagePolicyFormState =
| 'VALID'
| 'INVALID'
| 'CONFIRM'
| 'CONFIRM_UNPRIVILEGED'
| 'LOADING'
| 'SUBMITTED'
| 'SUBMITTED_NO_AGENTS'

View file

@ -41,7 +41,10 @@ const POLLING_INTERVAL_MS = 5 * 1000; // 5 sec
* @param policyId
* @returns agentIds
*/
export const usePollingAgentCount = (policyId: string, opts?: UsePollingAgentCountOptions) => {
export const usePollingAgentCount = (
policyId: string,
opts?: UsePollingAgentCountOptions
): { enrolledAgentIds: string[] } => {
const [agentIds, setAgentIds] = useState<string[]>([]);
const [didPollInitially, setDidPollInitially] = useState(false);
const timeout = useRef<number | undefined>(undefined);
@ -89,7 +92,7 @@ export const usePollingAgentCount = (policyId: string, opts?: UsePollingAgentCou
isAborted = true;
};
}, [agentIds, policyId, kuery, getNewAgentIds]);
return agentIds;
return { enrolledAgentIds: agentIds };
};
export const ConfirmAgentEnrollment: React.FunctionComponent<Props> = ({

View file

@ -65,19 +65,22 @@ export const AgentEnrollmentConfirmationStep = ({
: i18n.translate('xpack.fleet.agentEnrollment.stepAgentEnrollmentConfirmation', {
defaultMessage: 'Confirm agent enrollment',
}),
children:
!!isComplete || poll ? (
<ConfirmAgentEnrollment
policyId={selectedPolicyId}
troubleshootLink={troubleshootLink}
onClickViewAgents={onClickViewAgents}
agentCount={agentCount}
showLoading={!isComplete || showLoading}
isLongEnrollment={isLongEnrollment}
/>
) : (
<AgentEnrollmentPrePollInstructions troubleshootLink={troubleshootLink} />
),
children: (
<>
{!!isComplete || poll ? (
<ConfirmAgentEnrollment
policyId={selectedPolicyId}
troubleshootLink={troubleshootLink}
onClickViewAgents={onClickViewAgents}
agentCount={agentCount}
showLoading={!isComplete || showLoading}
isLongEnrollment={isLongEnrollment}
/>
) : (
<AgentEnrollmentPrePollInstructions troubleshootLink={troubleshootLink} />
)}
</>
),
status: !isComplete ? 'incomplete' : 'complete',
};
};

View file

@ -14,6 +14,7 @@ import type { EuiContainedStepProps } from '@elastic/eui/src/components/steps/st
import type { FullAgentPolicy } from '../../../../common/types/models/agent_policy';
import { API_VERSIONS } from '../../../../common/constants';
import { getRootIntegrations } from '../../../../common/services';
import { fullAgentPolicyToYaml, agentPolicyRouteService } from '../../../services';
import { getGcpIntegrationDetailsFromAgentPolicy } from '../../cloud_security_posture/services';
@ -168,6 +169,7 @@ export const StandaloneSteps: React.FunctionComponent<InstructionProps> = ({
installCommand: standaloneInstallCommands,
isK8s,
cloudSecurityIntegration,
rootIntegrations: getRootIntegrations(selectedPolicy?.package_policies ?? []),
})
);
@ -225,7 +227,7 @@ export const ManagedSteps: React.FunctionComponent<InstructionProps> = ({
const apiKeyData = apiKey?.data;
const enrollToken = apiKey.data ? apiKey.data.item.api_key : '';
const enrolledAgentIds = usePollingAgentCount(selectedPolicy?.id || '');
const { enrolledAgentIds } = usePollingAgentCount(selectedPolicy?.id || '');
const agentVersion = useAgentVersion();
@ -309,6 +311,7 @@ export const ManagedSteps: React.FunctionComponent<InstructionProps> = ({
cloudSecurityIntegration,
fleetServerHost,
enrollToken,
rootIntegrations: getRootIntegrations(selectedPolicy?.package_policies ?? []),
})
);
}

View file

@ -29,6 +29,7 @@ export const InstallManagedAgentStep = ({
isComplete,
fullCopyButton,
onCopy,
rootIntegrations,
}: {
selectedApiKeyId?: string;
apiKeyData?: GetOneEnrollmentAPIKeyResponse | null;
@ -40,6 +41,7 @@ export const InstallManagedAgentStep = ({
isComplete?: boolean;
fullCopyButton?: boolean;
onCopy?: () => void;
rootIntegrations?: Array<{ name: string; title: string }>;
}): EuiContainedStepProps => {
const nonCompleteStatus = selectedApiKeyId ? undefined : 'disabled';
const status = isComplete ? 'complete' : nonCompleteStatus;
@ -58,6 +60,7 @@ export const InstallManagedAgentStep = ({
onCopy={onCopy}
fullCopyButton={fullCopyButton}
fleetServerHost={fleetServerHost}
rootIntegrations={rootIntegrations}
/>
) : (
<React.Fragment />

View file

@ -23,6 +23,7 @@ export const InstallStandaloneAgentStep = ({
isComplete,
fullCopyButton,
onCopy,
rootIntegrations,
}: {
installCommand: CommandsByPlatform;
isK8s?: K8sMode;
@ -30,6 +31,7 @@ export const InstallStandaloneAgentStep = ({
isComplete?: boolean;
fullCopyButton?: boolean;
onCopy?: () => void;
rootIntegrations?: Array<{ name: string; title: string }>;
}): EuiContainedStepProps => {
return {
title: i18n.translate('xpack.fleet.agentEnrollment.stepEnrollAndRunAgentTitle', {
@ -43,6 +45,7 @@ export const InstallStandaloneAgentStep = ({
onCopy={onCopy}
fullCopyButton={fullCopyButton}
isManaged={false}
rootIntegrations={rootIntegrations}
/>
),
status: isComplete ? 'complete' : undefined,

View file

@ -14,6 +14,8 @@ import { InstallationMessage } from '../agent_enrollment_flyout/installation_mes
import type { K8sMode, CloudSecurityIntegration } from '../agent_enrollment_flyout/types';
import { PlatformSelector } from '../platform_selector';
import { RootPrivilegesCallout } from './root_privileges_callout';
interface Props {
installCommand: CommandsByPlatform;
isK8s: K8sMode | undefined;
@ -23,6 +25,7 @@ interface Props {
fullCopyButton?: boolean;
isManaged?: boolean;
onCopy?: () => void;
rootIntegrations?: Array<{ name: string; title: string }>;
}
export const InstallSection: React.FunctionComponent<Props> = ({
@ -34,10 +37,12 @@ export const InstallSection: React.FunctionComponent<Props> = ({
fullCopyButton = false,
isManaged = true,
onCopy,
rootIntegrations,
}) => {
return (
<>
<InstallationMessage isK8s={isK8s} isManaged={isManaged} />
<RootPrivilegesCallout rootIntegrations={rootIntegrations} />
<PlatformSelector
fullCopyButton={fullCopyButton}
onCopy={onCopy}

View file

@ -0,0 +1,36 @@
/*
* 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 React from 'react';
import { cleanup, waitFor } from '@testing-library/react';
import { createFleetTestRendererMock } from '../../mock';
import { RootPrivilegesCallout } from './root_privileges_callout';
describe('RootPrivilegesCallout', () => {
function render(rootIntegrations?: Array<{ name: string; title: string }>) {
cleanup();
const renderer = createFleetTestRendererMock();
const results = renderer.render(<RootPrivilegesCallout rootIntegrations={rootIntegrations} />);
return results;
}
it('should render callout requiring root privileges', async () => {
const renderResult = render([{ name: 'auditd_manager', title: 'Auditd Manager' }]);
await waitFor(() => {
expect(renderResult.getByText('Root privileges required')).toBeInTheDocument();
expect(renderResult.getByTestId('rootPrivilegesCallout').textContent).toContain(
'This agent policy contains the following integrations that require Elastic Agents to have root privileges. To ensure that all data required by the integrations can be collected, enroll the agents using an account with root privileges.'
);
expect(renderResult.getByText('Auditd Manager')).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,40 @@
/*
* 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 React from 'react';
import { EuiCallOut, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
export const RootPrivilegesCallout: React.FC<{
rootIntegrations?: Array<{ name: string; title: string }>;
}> = ({ rootIntegrations = [] }) => {
return rootIntegrations.length > 0 ? (
<>
<EuiCallOut
color="warning"
iconType="warning"
title={i18n.translate('xpack.fleet.agentEnrollmentCallout.rootPrivilegesTitle', {
defaultMessage: 'Root privileges required',
})}
data-test-subj="rootPrivilegesCallout"
>
<FormattedMessage
id="xpack.fleet.agentEnrollmentCallout.rootPrivilegesMessage"
defaultMessage="This agent policy contains the following integrations that require Elastic Agents to have root privileges.
To ensure that all data required by the integrations can be collected, enroll the agents using an account with root privileges."
/>
<ul>
{rootIntegrations.map((item) => (
<li key={item.name}>{item.title}</li>
))}
</ul>
</EuiCallOut>
<EuiSpacer size="m" />
</>
) : null;
};

View file

@ -86,6 +86,7 @@ import {
} from './migrations/security_solution/to_v8_11_0_2';
import { settingsV1 } from './model_versions/v1';
import { packagePolicyV10OnWriteScanFix } from './model_versions/security_solution';
import { migratePackagePolicySetRequiresRootToV8150 } from './migrations/to_v8_15_0';
/*
* Saved object types and mappings
@ -433,6 +434,7 @@ export const getSavedObjectTypes = (
name: { type: 'keyword' },
title: { type: 'keyword' },
version: { type: 'keyword' },
requires_root: { type: 'boolean' },
},
},
elasticsearch: {
@ -549,6 +551,20 @@ export const getSavedObjectTypes = (
},
],
},
'11': {
changes: [
{
type: 'mappings_addition',
addedMappings: {
package: { properties: { requires_root: { type: 'boolean' } } },
},
},
{
type: 'data_backfill',
backfillFn: migratePackagePolicySetRequiresRootToV8150,
},
],
},
},
migrations: {
'7.10.0': migratePackagePolicyToV7100,

View file

@ -0,0 +1,85 @@
/*
* 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 {
createModelVersionTestMigrator,
type ModelVersionTestMigrator,
} from '@kbn/core-test-helpers-model-versions';
import type { SavedObject } from '@kbn/core-saved-objects-server';
import type { PackagePolicy } from '../../../common';
import { PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '../../../common';
import { getSavedObjectTypes } from '..';
const getPolicyDoc = (packageName: string): SavedObject<PackagePolicy> => {
return {
id: 'mock-saved-object-id',
attributes: {
name: 'Some Policy Name',
package: {
name: packageName,
title: '',
version: '',
},
id: 'package-policy-1',
policy_id: '',
enabled: true,
namespace: '',
revision: 0,
updated_at: '',
updated_by: '',
created_at: '',
created_by: '',
inputs: [],
},
type: PACKAGE_POLICY_SAVED_OBJECT_TYPE,
references: [],
};
};
describe('8.15.0 Requires Root Package Policy migration', () => {
let migrator: ModelVersionTestMigrator;
beforeEach(() => {
migrator = createModelVersionTestMigrator({
type: getSavedObjectTypes()[PACKAGE_POLICY_SAVED_OBJECT_TYPE],
});
});
describe('backfilling `requires_root`', () => {
it('should backfill `requires_root` field as `true` for `endpoint` package on Kibana update', () => {
const migratedPolicyConfigSO = migrator.migrate<PackagePolicy, PackagePolicy>({
document: getPolicyDoc('endpoint'),
fromVersion: 10,
toVersion: 11,
});
expect(migratedPolicyConfigSO.attributes.package?.requires_root).toBe(true);
});
it('should backfill `requires_root` field as `true` for `auditd_manager` package on Kibana update', () => {
const migratedPolicyConfigSO = migrator.migrate<PackagePolicy, PackagePolicy>({
document: getPolicyDoc('auditd_manager'),
fromVersion: 10,
toVersion: 11,
});
expect(migratedPolicyConfigSO.attributes.package?.requires_root).toBe(true);
});
it('should not backfill `requires_root` field as `true` for other package on Kibana update', () => {
const migratedPolicyConfigSO = migrator.migrate<PackagePolicy, PackagePolicy>({
document: getPolicyDoc('other'),
fromVersion: 10,
toVersion: 11,
});
expect(migratedPolicyConfigSO.attributes.package!.requires_root).toBe(undefined);
});
});
});

View file

@ -0,0 +1,39 @@
/*
* 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 {
SavedObjectModelDataBackfillFn,
SavedObjectUnsanitizedDoc,
} from '@kbn/core-saved-objects-server';
import type { PackagePolicy } from '../../../common';
// backfill existing package policies with packages requiring root in 8.15.0
const ROOT_PACKAGES = [
'endpoint',
'universal_profiling_agent',
'system_audit',
'network_traffic',
'fim',
'auditd_manager',
];
export const migratePackagePolicySetRequiresRootToV8150: SavedObjectModelDataBackfillFn<
PackagePolicy,
PackagePolicy
> = (packagePolicyDoc) => {
const updatedPackagePolicyDoc: SavedObjectUnsanitizedDoc<PackagePolicy> = packagePolicyDoc;
if (
updatedPackagePolicyDoc.attributes.package &&
ROOT_PACKAGES.includes(updatedPackagePolicyDoc.attributes.package.name)
) {
updatedPackagePolicyDoc.attributes.package.requires_root = true;
}
return { attributes: updatedPackagePolicyDoc.attributes };
};

View file

@ -383,7 +383,10 @@ class AgentPolicyService {
throw new FleetError(agentPolicySO.error.message);
}
const agentPolicy = { id: agentPolicySO.id, ...agentPolicySO.attributes };
const agentPolicy = {
id: agentPolicySO.id,
...agentPolicySO.attributes,
};
if (withPackagePolicies) {
agentPolicy.package_policies =

View file

@ -328,6 +328,13 @@ class PackagePolicyClientImpl implements PackagePolicyClient {
logger,
});
}
if (enrichedPackagePolicy.package && pkgInfo?.agent?.privileges?.root) {
enrichedPackagePolicy.package = {
...enrichedPackagePolicy.package,
requires_root: pkgInfo?.agent?.privileges?.root ?? false,
};
}
}
const isoDate = new Date().toISOString();
@ -457,6 +464,13 @@ class PackagePolicyClientImpl implements PackagePolicyClient {
: inputs;
elasticsearch = pkgInfo?.elasticsearch;
if (packagePolicy.package && pkgInfo?.agent?.privileges?.root) {
packagePolicy.package = {
...packagePolicy.package,
requires_root: pkgInfo?.agent?.privileges?.root ?? false,
};
}
}
policiesToCreate.push({
@ -862,6 +876,13 @@ class PackagePolicyClientImpl implements PackagePolicyClient {
assetsMap
);
elasticsearchPrivileges = pkgInfo.elasticsearch?.privileges;
if (restOfPackagePolicy.package && pkgInfo?.agent?.privileges?.root) {
restOfPackagePolicy.package = {
...restOfPackagePolicy.package,
requires_root: pkgInfo?.agent?.privileges?.root ?? false,
};
}
}
// Handle component template/mappings updates for experimental features, e.g. synthetic source
@ -1042,6 +1063,13 @@ class PackagePolicyClientImpl implements PackagePolicyClient {
assetsMap
);
elasticsearchPrivileges = pkgInfo.elasticsearch?.privileges;
if (restOfPackagePolicy.package && pkgInfo?.agent?.privileges?.root) {
restOfPackagePolicy.package = {
...restOfPackagePolicy.package,
requires_root: pkgInfo?.agent?.privileges?.root ?? false,
};
}
}
}

View file

@ -105,6 +105,7 @@ const PackagePolicyBaseSchema = {
title: schema.string(),
version: schema.string(),
experimental_data_stream_features: schema.maybe(ExperimentalDataStreamFeatures),
requires_root: schema.maybe(schema.boolean()),
})
),
// Deprecated TODO create remove issue
@ -148,6 +149,7 @@ const CreatePackagePolicyProps = {
title: schema.maybe(schema.string()),
version: schema.string(),
experimental_data_stream_features: schema.maybe(ExperimentalDataStreamFeatures),
requires_root: schema.maybe(schema.boolean()),
})
),
// Deprecated TODO create remove issue
@ -222,6 +224,7 @@ export const SimplifiedCreatePackagePolicyRequestBodySchema =
name: schema.string(),
version: schema.string(),
experimental_data_stream_features: schema.maybe(ExperimentalDataStreamFeatures),
requires_root: schema.maybe(schema.boolean()),
}),
});

View file

@ -0,0 +1,133 @@
/*
* 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 expect from '@kbn/expect';
import { v4 as uuidv4 } from 'uuid';
import { FtrProviderContext } from '../../../api_integration/ftr_provider_context';
import { setupFleetAndAgents } from '../agents/services';
import { skipIfNoDockerRegistry } from '../../helpers';
export default function (providerContext: FtrProviderContext) {
const { getService } = providerContext;
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
describe('agent policy with root integrations', () => {
skipIfNoDockerRegistry(providerContext);
before(async () => {
await esArchiver.load('x-pack/test/functional/es_archives/fleet/empty_fleet_server');
});
setupFleetAndAgents(providerContext);
after(async () => {
await esArchiver.unload('x-pack/test/functional/es_archives/fleet/empty_fleet_server');
});
describe('root integrations', () => {
before(async () => {
await supertest
.post(`/api/fleet/epm/packages/auditd_manager/1.16.3`)
.set('kbn-xsrf', 'xxxx')
.send({ force: true })
.expect(200);
});
after(async () => {
await supertest
.delete(`/api/fleet/epm/packages/auditd_manager/1.16.3`)
.set('kbn-xsrf', 'xxxx')
.send({ force: true })
.expect(200);
});
it('should have root integrations in agent policy response', async () => {
// Create agent policy
const {
body: {
item: { id: agentPolicyId },
},
} = await supertest
.post(`/api/fleet/agent_policies`)
.set('kbn-xsrf', 'xxxx')
.send({
name: `Test policy ${uuidv4()}`,
namespace: 'default',
monitoring_enabled: [],
})
.expect(200);
await supertest
.post(`/api/fleet/package_policies`)
.set('kbn-xsrf', 'xxxx')
.send({
name: `auditd-${uuidv4()}`,
description: '',
namespace: 'default',
policy_id: agentPolicyId,
package: {
name: 'auditd_manager',
version: '1.16.3',
},
inputs: [
{
type: 'audit/auditd',
policy_template: 'auditd',
enabled: true,
streams: [
{
enabled: true,
data_stream: { type: 'logs', dataset: 'auditd_manager.auditd' },
vars: {
socket_type: { value: '', type: 'select' },
immutable: { value: false, type: 'bool' },
resolve_ids: { value: true, type: 'bool' },
failure_mode: { value: 'silent', type: 'text' },
audit_rules: { type: 'textarea' },
audit_rule_files: { type: 'text' },
preserve_original_event: { value: false, type: 'bool' },
backlog_limit: { value: 8192, type: 'text' },
rate_limit: { value: 0, type: 'text' },
include_warnings: { value: false, type: 'bool' },
backpressure_strategy: { value: 'auto', type: 'text' },
tags: { value: ['auditd_manager-auditd'], type: 'text' },
processors: { type: 'yaml' },
},
},
],
},
],
})
.expect(200);
// Fetch the agent policy
const {
body: { item: agentPolicy },
} = await supertest
.get(`/api/fleet/agent_policies/${agentPolicyId}`)
.set('kbn-xsrf', 'xxxx');
// Check that the root integrations are correct
expect(
Object.values(agentPolicy.package_policies.map((policy: any) => policy.package))
).to.eql([
{
name: 'auditd_manager',
title: 'Auditd Manager',
requires_root: true,
version: '1.16.3',
},
]);
// Cleanup agent and package policy
await supertest
.post(`/api/fleet/agent_policies/delete`)
.send({ agentPolicyId })
.set('kbn-xsrf', 'xxxx')
.expect(200);
});
});
});
}

View file

@ -11,5 +11,6 @@ export default function loadTests({ loadTestFile }) {
loadTestFile(require.resolve('./agent_policy'));
loadTestFile(require.resolve('./agent_policy_datastream_permissions'));
loadTestFile(require.resolve('./privileges'));
loadTestFile(require.resolve('./agent_policy_root_integrations'));
});
}

View file

@ -42,6 +42,12 @@ export default function (providerContext: FtrProviderContext) {
describe('should respond with correct enrollment settings', async function () {
before(async () => {
await esArchiver.load('x-pack/test/functional/es_archives/fleet/fleet_server');
// package verification error without force
await supertest
.post(`/api/fleet/epm/packages/fleet_server`)
.set('kbn-xsrf', 'xxxx')
.send({ force: true })
.expect(200);
});
after(async () => {
await esArchiver.unload('x-pack/test/functional/es_archives/fleet/fleet_server');