diff --git a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts index 896cd26df093c..010d101fbe9d7 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts @@ -105,7 +105,7 @@ describe('checking migration metadata changes on all registered SO types', () => "ingest-agent-policies": "e5bb18f8c1d1106139e82fccb93fce01b21fde9b", "ingest-download-sources": "95a15b6589ef46e75aca8f7e534c493f99cc3ccd", "ingest-outputs": "f5adeb3f6abc732a6067137e170578dbf1f58c62", - "ingest-package-policies": "98a5f5defe00d606bfaa64f80bd745ff1465df18", + "ingest-package-policies": "6dc1c9b80a8dc95fbc9c6d9b73dfc56a098eb440", "ingest_manager_settings": "fb75bff08a8de3435b23664b1191f9244a255701", "inventory-view": "6d47ef0b38166ecbd1c2fc7394599a4500db1ae4", "kql-telemetry": "23ed96ff02cd69cbfaa22f313cae3a54c434db51", diff --git a/src/dev/precommit_hook/casing_check_config.js b/src/dev/precommit_hook/casing_check_config.js index 8adbd0a3d672e..5d34a004d8a99 100644 --- a/src/dev/precommit_hook/casing_check_config.js +++ b/src/dev/precommit_hook/casing_check_config.js @@ -29,6 +29,7 @@ export const IGNORE_FILE_GLOBS = [ 'x-pack/plugins/canvas/server/templates/assets/*.{png,jpg,svg}', 'x-pack/plugins/cases/docs/**/*', 'x-pack/plugins/monitoring/public/lib/jquery_flot/**/*', + 'x-pack/plugins/fleet/cypress/packages/*.zip', '**/.*', '**/__mocks__/**/*', 'x-pack/docs/**/*', diff --git a/x-pack/plugins/fleet/cypress/e2e/package_policy_real.cy.ts b/x-pack/plugins/fleet/cypress/e2e/package_policy_real.cy.ts new file mode 100644 index 0000000000000..00d9c4547966f --- /dev/null +++ b/x-pack/plugins/fleet/cypress/e2e/package_policy_real.cy.ts @@ -0,0 +1,98 @@ +/* + * 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 { + ADD_INTEGRATION_POLICY_BTN, + CREATE_PACKAGE_POLICY_SAVE_BTN, + INTEGRATION_NAME_LINK, + POLICY_EDITOR, +} from '../screens/integrations'; +import { EXISTING_HOSTS_TAB } from '../screens/fleet'; +import { CONFIRM_MODAL } from '../screens/navigation'; + +const TEST_PACKAGE = 'input_package-1.0.0'; +const agentPolicyId = 'test-input-package-policy'; +const agentPolicyName = 'Test input package policy'; +const inputPackagePolicyName = 'input-package-policy'; + +function editPackagePolicyandShowAdvanced() { + cy.visit(`/app/integrations/detail/${TEST_PACKAGE}/policies`); + + cy.getBySel(INTEGRATION_NAME_LINK).contains(inputPackagePolicyName).click(); + + cy.get('button').contains('Change defaults').click(); + cy.get('[data-test-subj^="advancedStreamOptionsToggle"]').click(); +} +describe('Input package create and edit package policy', () => { + before(() => { + cy.task('installTestPackage', TEST_PACKAGE); + + cy.request({ + method: 'POST', + url: `/api/fleet/agent_policies`, + body: { + id: agentPolicyId, + name: agentPolicyName, + description: 'desc', + namespace: 'default', + monitoring_enabled: [], + }, + headers: { 'kbn-xsrf': 'cypress' }, + }); + }); + after(() => { + // delete agent policy + cy.request({ + method: 'POST', + url: `/api/fleet/agent_policies/delete`, + headers: { 'kbn-xsrf': 'cypress' }, + body: JSON.stringify({ + agentPolicyId, + }), + }); + cy.task('uninstallTestPackage', TEST_PACKAGE); + }); + it('should successfully create a package policy', () => { + cy.visit(`/app/integrations/detail/${TEST_PACKAGE}/overview`); + cy.getBySel(ADD_INTEGRATION_POLICY_BTN).click(); + + cy.getBySel(POLICY_EDITOR.POLICY_NAME_INPUT).click().clear().type(inputPackagePolicyName); + cy.getBySel('multiTextInput-paths') + .find('[data-test-subj="multiTextInputRow-0"]') + .click() + .type('/var/log/test.log'); + + cy.getBySel('multiTextInput-tags') + .find('[data-test-subj="multiTextInputRow-0"]') + .click() + .type('tag1'); + + cy.getBySel(POLICY_EDITOR.DATASET_SELECT).click().type('testdataset'); + + cy.getBySel(EXISTING_HOSTS_TAB).click(); + + cy.getBySel(POLICY_EDITOR.AGENT_POLICY_SELECT).click().get(`#${agentPolicyId}`).click(); + cy.wait(500); // wait for policy id to be set + cy.getBySel(CREATE_PACKAGE_POLICY_SAVE_BTN).click(); + + cy.getBySel(CONFIRM_MODAL.CANCEL_BUTTON).click(); + }); + + it('should show pipelines editor with link to pipeline', () => { + editPackagePolicyandShowAdvanced(); + cy.getBySel(POLICY_EDITOR.INSPECT_PIPELINES_BTN).click(); + cy.getBySel(CONFIRM_MODAL.CONFIRM_BUTTON).click(); + cy.get('body').should('not.contain', 'Pipeline not found'); + cy.get('body').should('contain', '"managed_by": "fleet"'); + }); + it('should show mappings editor with link to create custom template', () => { + editPackagePolicyandShowAdvanced(); + cy.getBySel(POLICY_EDITOR.EDIT_MAPPINGS_BTN).click(); + cy.getBySel(CONFIRM_MODAL.CONFIRM_BUTTON).click(); + cy.get('body').should('contain', 'logs-testdataset@custom'); + }); +}); diff --git a/x-pack/plugins/fleet/cypress/packages/input_package-1.0.0.zip b/x-pack/plugins/fleet/cypress/packages/input_package-1.0.0.zip new file mode 100644 index 0000000000000..639072cab5ea7 Binary files /dev/null and b/x-pack/plugins/fleet/cypress/packages/input_package-1.0.0.zip differ diff --git a/x-pack/plugins/fleet/cypress/plugins/index.ts b/x-pack/plugins/fleet/cypress/plugins/index.ts index 17825ba12a2bb..ee01dd20c470c 100644 --- a/x-pack/plugins/fleet/cypress/plugins/index.ts +++ b/x-pack/plugins/fleet/cypress/plugins/index.ts @@ -5,12 +5,42 @@ * 2.0. */ +import { promisify } from 'util'; + +import fs from 'fs'; + +import fetch from 'node-fetch'; import { createEsClientForTesting } from '@kbn/test'; const plugin: Cypress.PluginConfig = (on, config) => { const client = createEsClientForTesting({ esUrl: config.env.ELASTICSEARCH_URL, }); + + async function kibanaFetch(opts: { + method: string; + path: string; + body?: any; + contentType?: string; + }) { + const { method, path, body, contentType } = opts; + const Authorization = `Basic ${Buffer.from( + `elastic:${config.env.ELASTICSEARCH_PASSWORD}` + ).toString('base64')}`; + + const url = `${config.env.KIBANA_URL}${path}`; + const res = await fetch(url, { + method, + headers: { + 'kbn-xsrf': 'cypress', + 'Content-Type': contentType || 'application/json', + Authorization, + }, + ...(body ? { body } : {}), + }); + + return res.json(); + } on('task', { async insertDoc({ index, doc, id }: { index: string; doc: any; id: string }) { return client.create({ id, document: doc, index, refresh: 'wait_for' }); @@ -37,6 +67,23 @@ const plugin: Cypress.PluginConfig = (on, config) => { conflicts: 'proceed', }); }, + async installTestPackage(packageName: string) { + const zipPath = require.resolve('../packages/' + packageName + '.zip'); + const zipContent = await promisify(fs.readFile)(zipPath, 'base64'); + return kibanaFetch({ + method: 'POST', + path: '/api/fleet/epm/packages', + body: Buffer.from(zipContent, 'base64'), + contentType: 'application/zip', + }); + }, + + async uninstallTestPackage(packageName: string) { + return kibanaFetch({ + method: 'DELETE', + path: `/api/fleet/epm/packages/${packageName}`, + }); + }, }); }; diff --git a/x-pack/plugins/fleet/cypress/screens/integrations.ts b/x-pack/plugins/fleet/cypress/screens/integrations.ts index 63b13d8ff7fd3..2faa0ade447a6 100644 --- a/x-pack/plugins/fleet/cypress/screens/integrations.ts +++ b/x-pack/plugins/fleet/cypress/screens/integrations.ts @@ -35,6 +35,14 @@ export const SETTINGS = { UNINSTALL_ASSETS_BTN: 'uninstallAssetsButton', }; +export const POLICY_EDITOR = { + POLICY_NAME_INPUT: 'packagePolicyNameInput', + DATASET_SELECT: 'datasetComboBox', + AGENT_POLICY_SELECT: 'agentPolicySelect', + INSPECT_PIPELINES_BTN: 'datastreamInspectPipelineBtn', + EDIT_MAPPINGS_BTN: 'datastreamEditMappingsBtn', +}; + export const INTEGRATION_POLICIES_UPGRADE_CHECKBOX = 'epmDetails.upgradePoliciesCheckbox'; export const getIntegrationCard = (integration: string) => `integration-card:epr:${integration}`; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/datastream_hooks.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/datastream_hooks.tsx index d8e21fce7c306..de740b99d97ce 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/datastream_hooks.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/datastream_hooks.tsx @@ -6,8 +6,9 @@ */ import { useRouteMatch } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; -import { useLink } from '../../../../hooks'; +import { sendRequestForRq, useLink } from '../../../../hooks'; export function usePackagePolicyEditorPageUrl(dataStreamId?: string) { const { @@ -27,3 +28,32 @@ export function usePackagePolicyEditorPageUrl(dataStreamId?: string) { return `${baseUrl}${dataStreamId ? `?datastreamId=${encodeURIComponent(dataStreamId)}` : ''}`; } + +export function useIndexTemplateExists( + templateName: string, + enabled: boolean +): { + exists?: boolean; + isLoading: boolean; +} { + const { data, isLoading } = useQuery( + ['indexTemplateExists', templateName], + () => + sendRequestForRq({ + path: `/api/index_management/index_templates/${templateName}`, + method: 'get', + }), + { enabled: enabled || !!templateName } + ); + + if (isLoading) { + return { + isLoading: true, + }; + } + + return { + exists: !!data, + isLoading: false, + }; +} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/datastream_mappings.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/datastream_mappings.tsx index 37963d4abe1ac..86d1f3404d4f9 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/datastream_mappings.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/datastream_mappings.tsx @@ -18,7 +18,11 @@ import { usePackagePolicyEditorPageUrl } from './datastream_hooks'; export interface PackagePolicyEditorDatastreamMappingsProps { packageInfo: PackageInfo; - packageInputStream: { id?: string; data_stream: { dataset: string; type: string } }; + packageInputStream: { + id?: string; + data_stream: { dataset: string; type: string }; + }; + customDataset?: string; } function useComponentTemplates(dataStream: { dataset: string; type: string }) { @@ -35,8 +39,10 @@ function useComponentTemplates(dataStream: { dataset: string; type: string }) { export const PackagePolicyEditorDatastreamMappings: React.FunctionComponent< PackagePolicyEditorDatastreamMappingsProps -> = ({ packageInputStream, packageInfo }) => { - const dataStream = packageInputStream.data_stream; +> = ({ packageInputStream, packageInfo, customDataset }) => { + const dataStream = customDataset + ? { ...packageInputStream.data_stream, dataset: customDataset } + : packageInputStream.data_stream; const pageUrl = usePackagePolicyEditorPageUrl(packageInputStream.id); const { application, docLinks } = useStartServices(); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/datastream_pipelines.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/datastream_pipelines.tsx index 75e95a7975f61..b2325dcc15213 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/datastream_pipelines.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/datastream_pipelines.tsx @@ -31,6 +31,7 @@ import { usePackagePolicyEditorPageUrl } from './datastream_hooks'; export interface PackagePolicyEditorDatastreamPipelinesProps { packageInfo: PackageInfo; packageInputStream: { id?: string; data_stream: { dataset: string; type: string } }; + customDataset?: string; } interface PipelineItem { @@ -92,8 +93,10 @@ function useDatastreamIngestPipelines( export const PackagePolicyEditorDatastreamPipelines: React.FunctionComponent< PackagePolicyEditorDatastreamPipelinesProps -> = ({ packageInputStream, packageInfo }) => { - const dataStream = packageInputStream.data_stream; +> = ({ packageInputStream, packageInfo, customDataset }) => { + const dataStream = customDataset + ? { ...packageInputStream.data_stream, dataset: customDataset } + : packageInputStream.data_stream; const { application, share, docLinks } = useStartServices(); const ingestPipelineLocator = share.url.locators.get('INGEST_PIPELINES_APP_LOCATOR'); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/dataset_combo.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/dataset_combo.tsx index 680da0fd75012..31fcbee8d6821 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/dataset_combo.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/dataset_combo.tsx @@ -94,6 +94,7 @@ export const DatasetComboBox: React.FC<{ })} isClearable={false} isDisabled={isDisabled} + data-test-subj="datasetComboBox" /> {valueAsOption && valueAsOption.value.package !== pkgName && ( <> diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/multi_text_input.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/multi_text_input.tsx index 66ec097624c41..ebcf666d24710 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/multi_text_input.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/multi_text_input.tsx @@ -25,6 +25,7 @@ interface Props { errors?: Array<{ message: string; index?: number }>; isInvalid?: boolean; isDisabled?: boolean; + 'data-test-subj'?: string; } interface RowProps { @@ -69,6 +70,7 @@ const Row: FunctionComponent = ({ autoFocus={autoFocus} disabled={isDisabled} onBlur={onBlur} + data-test-subj={`multiTextInputRow-${index}`} /> {showDeleteButton && ( @@ -99,6 +101,7 @@ export const MultiTextInput: FunctionComponent = ({ isInvalid, isDisabled, errors, + 'data-test-subj': dataTestSubj, }) => { const [autoFocus, setAutoFocus] = useState(false); const [rows, setRows] = useState(() => defaultValue(value)); @@ -139,7 +142,7 @@ export const MultiTextInput: FunctionComponent = ({ return ( <> - + {rows.map((row, idx) => ( ( !!packagePolicyInputStream.id && packagePolicyInputStream.id === defaultDataStreamId; const isPackagePolicyEdit = !!packagePolicyId; - const isInputOnlyPackage = packageInfo.type === 'input'; - const hasDatasetVar = packageInputStream.vars?.some( - (varDef) => varDef.name === DATASET_VAR_NAME - ); - const showPipelinesAndMappings = !isInputOnlyPackage && !hasDatasetVar; + const customDatasetVar = packagePolicyInputStream.vars?.[DATASET_VAR_NAME]; + const customDatasetVarValue = customDatasetVar?.value?.dataset || customDatasetVar?.value; + + const { exists: indexTemplateExists, isLoading: isLoadingIndexTemplate } = + useIndexTemplateExists( + getRegistryDataStreamAssetBaseName({ + dataset: customDatasetVarValue, + type: packageInputStream.data_stream.type, + }), + isPackagePolicyEdit + ); + + // only show pipelines and mappings if the matching index template exists + // in the legacy case (e.g logs package pre 2.0.0) the index template will not exist + // because we allowed dataset to be customized but didnt create a matching index template + // for the new dataset. + const showPipelinesAndMappings = !isLoadingIndexTemplate && indexTemplateExists; + useEffect(() => { if (isDefaultDatastream && containerRef.current) { containerRef.current.scrollIntoView(); @@ -249,6 +267,7 @@ export const PackagePolicyInputStreamConfig = memo( iconType={isShowingAdvanced ? 'arrowDown' : 'arrowRight'} onClick={() => setIsShowingAdvanced(!isShowingAdvanced)} flush="left" + data-test-subj={`advancedStreamOptionsToggle-${packagePolicyInputStream.id}`} > ( ); })} - {/* Only show datastream pipelines and mappings on edit and not for input packages*/} {isPackagePolicyEdit && showPipelinesAndMappings && ( <> diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/package_policy_input_var_field.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/package_policy_input_var_field.tsx index 27457dedbc9b1..827d1fb2722c2 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/package_policy_input_var_field.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/package_policy_input_var_field.tsx @@ -63,7 +63,7 @@ export const PackagePolicyInputVarField: React.FunctionComponent<{ const isInvalid = (isDirty || forceShowErrors) && !!varErrors; const errors = isInvalid ? varErrors : null; const fieldLabel = title || name; - + const fieldTestSelector = fieldLabel.replace(/\s/g, '-').toLowerCase(); const field = useMemo(() => { if (multi) { return ( @@ -72,6 +72,7 @@ export const PackagePolicyInputVarField: React.FunctionComponent<{ onChange={onChange} onBlur={() => setIsDirty(true)} isDisabled={frozen} + data-test-subj={`multiTextInput-${fieldTestSelector}`} /> ); } @@ -96,6 +97,7 @@ export const PackagePolicyInputVarField: React.FunctionComponent<{ onBlur={() => setIsDirty(true)} disabled={frozen} resize="vertical" + data-test-subj={`textAreaInput-${fieldTestSelector}`} /> ); case 'yaml': @@ -142,6 +144,7 @@ export const PackagePolicyInputVarField: React.FunctionComponent<{ onChange={(e) => onChange(e.target.checked)} onBlur={() => setIsDirty(true)} disabled={frozen} + data-test-subj={`switch-${fieldTestSelector}`} /> ); case 'password': @@ -153,6 +156,7 @@ export const PackagePolicyInputVarField: React.FunctionComponent<{ onChange={(e) => onChange(e.target.value)} onBlur={() => setIsDirty(true)} disabled={frozen} + data-test-subj={`passwordInput-${fieldTestSelector}`} /> ); case 'select': @@ -177,6 +181,7 @@ export const PackagePolicyInputVarField: React.FunctionComponent<{ return onChange(newValue); }} onBlur={() => setIsDirty(true)} + data-test-subj={`select-${fieldTestSelector}`} /> ); default: @@ -187,6 +192,7 @@ export const PackagePolicyInputVarField: React.FunctionComponent<{ onChange={(e) => onChange(e.target.value)} onBlur={() => setIsDirty(true)} disabled={frozen} + data-test-subj={`textInput-${fieldTestSelector}`} /> ); } @@ -204,6 +210,7 @@ export const PackagePolicyInputVarField: React.FunctionComponent<{ isInvalid, fieldLabel, options, + fieldTestSelector, ]); // Boolean cannot be optional by default set to false diff --git a/x-pack/plugins/fleet/server/plugin.ts b/x-pack/plugins/fleet/server/plugin.ts index 4e9a7f19b1818..269e220e58da7 100644 --- a/x-pack/plugins/fleet/server/plugin.ts +++ b/x-pack/plugins/fleet/server/plugin.ts @@ -35,7 +35,11 @@ import type { EncryptedSavedObjectsPluginStart, EncryptedSavedObjectsPluginSetup, } from '@kbn/encrypted-saved-objects-plugin/server'; -import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/server'; +import type { + AuditLogger, + SecurityPluginSetup, + SecurityPluginStart, +} from '@kbn/security-plugin/server'; import type { PluginSetupContract as FeaturesPluginSetup } from '@kbn/features-plugin/server'; import type { TaskManagerSetupContract, @@ -111,6 +115,7 @@ import type { PackagePolicyService } from './services/package_policy_service'; import { PackagePolicyServiceImpl } from './services/package_policy'; import { registerFleetUsageLogger, startFleetUsageLogger } from './services/fleet_usage_logger'; import { CheckDeletedFilesTask } from './tasks/check_deleted_files_task'; +import { getRequestStore } from './services/request_store'; export interface FleetSetupDeps { security: SecurityPluginSetup; @@ -154,6 +159,7 @@ export interface FleetAppContext { telemetryEventsSender: TelemetryEventsSender; bulkActionsResolver: BulkActionsResolver; messageSigningService: MessageSigningServiceInterface; + auditLogger?: AuditLogger; } export type FleetSetupContract = void; @@ -362,39 +368,42 @@ export class FleetPlugin .getSavedObjects() .getScopedClient(request, { excludedExtensions: [SECURITY_EXTENSION_ID] }); - return { - get agentClient() { - const agentService = plugin.setupAgentService(esClient.asInternalUser, soClient); + const requestStore = getRequestStore(); - return { - asCurrentUser: agentService.asScoped(request), - asInternalUser: agentService.asInternalUser, - }; - }, - get packagePolicyService() { - const service = plugin.setupPackagePolicyService(); + return requestStore.run(request, () => { + return { + get agentClient() { + const agentService = plugin.setupAgentService(esClient.asInternalUser, soClient); - return { - asCurrentUser: service.asScoped(request), - asInternalUser: service.asInternalUser, - }; - }, - authz, + return { + asCurrentUser: agentService.asScoped(request), + asInternalUser: agentService.asInternalUser, + }; + }, + get packagePolicyService() { + const service = plugin.setupPackagePolicyService(); - get internalSoClient() { - // Use a lazy getter to avoid constructing this client when not used by a request handler - return getInternalSoClient(); - }, - get spaceId() { - return deps.spaces?.spacesService?.getSpaceId(request) ?? DEFAULT_SPACE_ID; - }, + return { + asCurrentUser: service.asScoped(request), + asInternalUser: service.asInternalUser, + }; + }, + authz, + get internalSoClient() { + // Use a lazy getter to avoid constructing this client when not used by a request handler + return getInternalSoClient(); + }, + get spaceId() { + return deps.spaces?.spacesService?.getSpaceId(request) ?? DEFAULT_SPACE_ID; + }, - get limitedToPackages() { - if (routeAuthz && routeAuthz.granted) { - return routeAuthz.scopeDataToPackages; - } - }, - }; + get limitedToPackages() { + if (routeAuthz && routeAuthz.granted) { + return routeAuthz.scopeDataToPackages; + } + }, + }; + }); } ); diff --git a/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts b/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts index 83e2600d1beb2..a31ba8fa5cb70 100644 --- a/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts @@ -267,11 +267,15 @@ export const deleteAgentPoliciesHandler: RequestHandler< const coreContext = await context.core; const soClient = coreContext.savedObjects.client; const esClient = coreContext.elasticsearch.client.asInternalUser; + const user = await appContextService.getSecurity()?.authc.getCurrentUser(request); try { const body: DeleteAgentPolicyResponse = await agentPolicyService.delete( soClient, esClient, - request.body.agentPolicyId + request.body.agentPolicyId, + { + user: user || undefined, + } ); return response.ok({ body, diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts index 2b1732142686b..8f0db94d1d31c 100644 --- a/x-pack/plugins/fleet/server/saved_objects/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/index.ts @@ -54,7 +54,10 @@ import { migrateInstallationToV860, migratePackagePolicyToV860, } from './migrations/to_v8_6_0'; -import { migratePackagePolicyToV870 } from './migrations/security_solution'; +import { + migratePackagePolicyToV870, + migratePackagePolicyToV880, +} from './migrations/security_solution'; /* * Saved object types and mappings @@ -212,6 +215,7 @@ const getSavedObjectTypes = (): { [key: string]: SavedObjectsType } => ({ '8.5.0': migratePackagePolicyToV850, '8.6.0': migratePackagePolicyToV860, '8.7.0': migratePackagePolicyToV870, + '8.8.0': migratePackagePolicyToV880, }, }, [PACKAGES_SAVED_OBJECT_TYPE]: { diff --git a/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/index.ts b/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/index.ts index f8c64a09fc4c8..4dd924e832a22 100644 --- a/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/index.ts @@ -17,3 +17,4 @@ export { migratePackagePolicyToV840 } from './to_v8_4_0'; export { migratePackagePolicyToV850 } from './to_v8_5_0'; export { migratePackagePolicyToV860 } from './to_v8_6_0'; export { migratePackagePolicyToV870 } from './to_v8_7_0'; +export { migratePackagePolicyToV880 } from './to_v8_8_0'; diff --git a/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v8_8_0.test.ts b/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v8_8_0.test.ts new file mode 100644 index 0000000000000..e360b655f2a61 --- /dev/null +++ b/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v8_8_0.test.ts @@ -0,0 +1,129 @@ +/* + * 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 { SavedObjectMigrationContext, SavedObjectUnsanitizedDoc } from '@kbn/core/server'; + +import type { PackagePolicy } from '../../../../common'; + +import { migratePackagePolicyToV880 as migration } from './to_v8_8_0'; + +describe('8.8.0 Endpoint Package Policy migration', () => { + const policyDoc = ({ meta = {} }) => { + return { + id: 'mock-saved-object-id', + attributes: { + name: 'Some Policy Name', + package: { + name: 'endpoint', + title: '', + version: '', + }, + id: 'endpoint', + policy_id: '', + enabled: true, + namespace: '', + revision: 0, + updated_at: '', + updated_by: '', + created_at: '', + created_by: '', + inputs: [ + { + type: 'endpoint', + enabled: true, + streams: [], + config: { + policy: { + value: { + meta: { ...meta }, + windows: {}, + mac: {}, + linux: {}, + }, + }, + }, + }, + ], + }, + type: ' nested', + }; + }; + + it('adds license to policy, defaulted to empty string', () => { + const initialDoc = policyDoc({}); + + const migratedDoc = policyDoc({ + meta: { license: '' }, + }); + + expect(migration(initialDoc, {} as SavedObjectMigrationContext)).toEqual(migratedDoc); + }); + + it('does not modify non-endpoint package policies', () => { + const doc: SavedObjectUnsanitizedDoc = { + id: 'mock-saved-object-id', + attributes: { + name: 'Some Policy Name', + package: { + name: 'notEndpoint', + title: '', + version: '', + }, + id: 'notEndpoint', + policy_id: '', + enabled: true, + namespace: '', + revision: 0, + updated_at: '', + updated_by: '', + created_at: '', + created_by: '', + inputs: [ + { + type: 'notEndpoint', + enabled: true, + streams: [], + config: {}, + }, + ], + }, + type: ' nested', + }; + + expect( + migration(doc, {} as SavedObjectMigrationContext) as SavedObjectUnsanitizedDoc + ).toEqual({ + attributes: { + name: 'Some Policy Name', + package: { + name: 'notEndpoint', + title: '', + version: '', + }, + id: 'notEndpoint', + policy_id: '', + enabled: true, + namespace: '', + revision: 0, + updated_at: '', + updated_by: '', + created_at: '', + created_by: '', + inputs: [ + { + type: 'notEndpoint', + enabled: true, + streams: [], + config: {}, + }, + ], + }, + type: ' nested', + id: 'mock-saved-object-id', + }); + }); +}); diff --git a/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v8_8_0.ts b/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v8_8_0.ts new file mode 100644 index 0000000000000..24b769bcdec9a --- /dev/null +++ b/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v8_8_0.ts @@ -0,0 +1,31 @@ +/* + * 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 { SavedObjectMigrationFn, SavedObjectUnsanitizedDoc } from '@kbn/core/server'; + +import type { PackagePolicy } from '../../../../common'; + +export const migratePackagePolicyToV880: SavedObjectMigrationFn = ( + packagePolicyDoc +) => { + if (packagePolicyDoc.attributes.package?.name !== 'endpoint') { + return packagePolicyDoc; + } + + const updatedPackagePolicyDoc: SavedObjectUnsanitizedDoc = packagePolicyDoc; + + const input = updatedPackagePolicyDoc.attributes.inputs[0]; + + if (input && input.config) { + const policy = input.config.policy.value; + + // For the migration, we add an empty string, and the license watcher will correct it if needed. + policy.meta = { license: '' }; + } + + return updatedPackagePolicyDoc; +}; diff --git a/x-pack/plugins/fleet/server/services/agent_policies/output_helpers.test.ts b/x-pack/plugins/fleet/server/services/agent_policies/output_helpers.test.ts index 629640c574b09..29d48d5d595ef 100644 --- a/x-pack/plugins/fleet/server/services/agent_policies/output_helpers.test.ts +++ b/x-pack/plugins/fleet/server/services/agent_policies/output_helpers.test.ts @@ -7,6 +7,8 @@ import { savedObjectsClientMock } from '@kbn/core/server/mocks'; +import { securityMock } from '@kbn/security-plugin/server/mocks'; + import { appContextService } from '..'; import { outputService } from '../output'; @@ -16,6 +18,10 @@ jest.mock('../app_context'); jest.mock('../output'); const mockedAppContextService = appContextService as jest.Mocked; +mockedAppContextService.getSecuritySetup.mockImplementation(() => ({ + ...securityMock.createSetup(), +})); + const mockedOutputService = outputService as jest.Mocked; function mockHasLicence(res: boolean) { diff --git a/x-pack/plugins/fleet/server/services/agent_policy.test.ts b/x-pack/plugins/fleet/server/services/agent_policy.test.ts index 1d2241696704d..9646b6e774223 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.test.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.test.ts @@ -6,18 +6,16 @@ */ import { elasticsearchServiceMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; - import { SavedObjectsErrorHelpers } from '@kbn/core/server'; +import { securityMock } from '@kbn/security-plugin/server/mocks'; import { PackagePolicyRestrictionRelatedError } from '../errors'; - import type { AgentPolicy, FullAgentPolicy, NewAgentPolicy, PreconfiguredAgentPolicy, } from '../types'; - import { AGENT_POLICY_SAVED_OBJECT_TYPE } from '../constants'; import { AGENT_POLICY_INDEX } from '../../common'; @@ -31,6 +29,8 @@ import { appContextService } from './app_context'; import { outputService } from './output'; import { downloadSourceService } from './download_source'; import { getFullAgentPolicy } from './agent_policies'; +import * as outputsHelpers from './agent_policies/outputs_helpers'; +import { auditLoggingService } from './audit_logging'; function getSavedObjectMock(agentPolicyAttributes: any) { const mock = savedObjectsClientMock.create(); @@ -70,9 +70,17 @@ jest.mock('./agent_policy_update'); jest.mock('./agents'); jest.mock('./package_policy'); jest.mock('./app_context'); +jest.mock('./audit_logging'); jest.mock('./agent_policies/full_agent_policy'); +jest.mock('./agent_policies/outputs_helpers'); const mockedAppContextService = appContextService as jest.Mocked; +mockedAppContextService.getSecuritySetup.mockImplementation(() => ({ + ...securityMock.createSetup(), +})); + +const mockedAuditLoggingService = auditLoggingService as jest.Mocked; +const mockOutputsHelpers = outputsHelpers as jest.Mocked; const mockedOutputService = outputService as jest.Mocked; const mockedDownloadSourceService = downloadSourceService as jest.Mocked< typeof downloadSourceService @@ -83,12 +91,6 @@ const mockedGetFullAgentPolicy = getFullAgentPolicy as jest.Mock< ReturnType >; -function getAgentPolicyUpdateMock() { - return agentPolicyUpdateEventHandler as unknown as jest.Mock< - typeof agentPolicyUpdateEventHandler - >; -} - function getAgentPolicyCreateMock() { const soClient = savedObjectsClientMock.create(); soClient.create.mockImplementation(async (type, attributes) => { @@ -103,8 +105,8 @@ function getAgentPolicyCreateMock() { } describe('agent policy', () => { - beforeEach(() => { - getAgentPolicyUpdateMock().mockClear(); + afterEach(() => { + jest.resetAllMocks(); }); describe('create', () => { @@ -141,6 +143,148 @@ describe('agent policy', () => { const [, attributes] = soClient.create.mock.calls[0]; expect(attributes).toHaveProperty('is_managed', true); }); + + it('should call audit logger', async () => { + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + const soClient = savedObjectsClientMock.create(); + + soClient.find.mockResolvedValueOnce({ + total: 0, + saved_objects: [], + per_page: 0, + page: 1, + }); + + soClient.create.mockResolvedValueOnce({ + id: 'test-agent-policy', + type: AGENT_POLICY_SAVED_OBJECT_TYPE, + attributes: {}, + references: [], + }); + + mockOutputsHelpers.validateOutputForPolicy.mockResolvedValueOnce(undefined); + + await agentPolicyService.create( + soClient, + esClient, + { + name: 'test', + namespace: 'default', + }, + { id: 'test-agent-policy' } + ); + + expect(mockedAuditLoggingService.writeCustomSoAuditLog).toHaveBeenCalledWith({ + action: 'create', + id: 'test-agent-policy', + savedObjectType: AGENT_POLICY_SAVED_OBJECT_TYPE, + }); + }); + }); + + // TODO: Add more test coverage to `get` service method + describe('get', () => { + it('should call audit logger', async () => { + const soClient = savedObjectsClientMock.create(); + + soClient.get.mockResolvedValueOnce({ + id: 'test-agent-policy', + attributes: {}, + references: [], + type: AGENT_POLICY_SAVED_OBJECT_TYPE, + }); + + await agentPolicyService.get(soClient, 'test-agent-policy', false); + + expect(mockedAuditLoggingService.writeCustomSoAuditLog).toBeCalledWith({ + action: 'get', + id: 'test-agent-policy', + savedObjectType: AGENT_POLICY_SAVED_OBJECT_TYPE, + }); + }); + }); + + describe('getByIDs', () => { + it('should call audit logger', async () => { + const soClient = savedObjectsClientMock.create(); + + soClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [ + { + id: 'test-agent-policy-1', + attributes: {}, + references: [], + type: AGENT_POLICY_SAVED_OBJECT_TYPE, + }, + { + id: 'test-agent-policy-2', + attributes: {}, + references: [], + type: AGENT_POLICY_SAVED_OBJECT_TYPE, + }, + ], + }); + + await agentPolicyService.getByIDs(soClient, ['test-agent-policy-1', 'test-agent-policy-2']); + + expect(mockedAuditLoggingService.writeCustomSoAuditLog).toHaveBeenNthCalledWith(1, { + action: 'get', + id: 'test-agent-policy-1', + savedObjectType: AGENT_POLICY_SAVED_OBJECT_TYPE, + }); + + expect(mockedAuditLoggingService.writeCustomSoAuditLog).toHaveBeenNthCalledWith(2, { + action: 'get', + id: 'test-agent-policy-2', + savedObjectType: AGENT_POLICY_SAVED_OBJECT_TYPE, + }); + }); + }); + + describe('list', () => { + it('should call audit logger', async () => { + const soClient = savedObjectsClientMock.create(); + + soClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: [ + { + id: 'test-agent-policy-1', + attributes: {}, + references: [], + type: AGENT_POLICY_SAVED_OBJECT_TYPE, + score: 0, + }, + { + id: 'test-agent-policy-2', + attributes: {}, + references: [], + type: AGENT_POLICY_SAVED_OBJECT_TYPE, + score: 0, + }, + ], + per_page: 0, + page: 1, + }); + + await agentPolicyService.list(soClient, { + page: 1, + perPage: 10, + kuery: '', + }); + + expect(mockedAuditLoggingService.writeCustomSoAuditLog).toHaveBeenNthCalledWith(1, { + action: 'find', + id: 'test-agent-policy-1', + savedObjectType: AGENT_POLICY_SAVED_OBJECT_TYPE, + }); + + expect(mockedAuditLoggingService.writeCustomSoAuditLog).toHaveBeenNthCalledWith(2, { + action: 'find', + id: 'test-agent-policy-2', + savedObjectType: AGENT_POLICY_SAVED_OBJECT_TYPE, + }); + }); }); describe('delete', () => { @@ -187,6 +331,16 @@ describe('agent policy', () => { ); } }); + + it('should call audit logger', async () => { + await agentPolicyService.delete(soClient, esClient, 'mocked'); + + expect(mockedAuditLoggingService.writeCustomSoAuditLog).toHaveBeenCalledWith({ + action: 'delete', + id: 'mocked', + savedObjectType: AGENT_POLICY_SAVED_OBJECT_TYPE, + }); + }); }); describe('bumpRevision', () => { @@ -406,6 +560,30 @@ describe('agent policy', () => { calledWith = soClient.update.mock.calls[1]; expect(calledWith[2]).toHaveProperty('is_managed', true); }); + + it('should call audit logger', async () => { + const soClient = savedObjectsClientMock.create(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + + soClient.get.mockResolvedValue({ + attributes: {}, + references: [], + id: 'test-agent-policy', + type: AGENT_POLICY_SAVED_OBJECT_TYPE, + }); + + await agentPolicyService.update(soClient, esClient, 'test-agent-policy', { + name: 'Test Agent Policy', + namespace: 'default', + is_managed: false, + }); + + expect(mockedAuditLoggingService.writeCustomSoAuditLog).toHaveBeenCalledWith({ + action: 'update', + id: 'test-agent-policy', + savedObjectType: AGENT_POLICY_SAVED_OBJECT_TYPE, + }); + }); }); describe('deployPolicy', () => { @@ -528,50 +706,75 @@ describe('agent policy', () => { ); }); - describe('ensurePreconfiguredAgentPolicy', () => { - it('should use preconfigured id if provided for policy', async () => { - const soClient = savedObjectsClientMock.create(); - const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - - const preconfiguredAgentPolicy: PreconfiguredAgentPolicy = { - id: 'my-unique-id', - name: 'My Preconfigured Policy', - package_policies: [ - { - name: 'my-package-policy', - id: 'my-package-policy-id', - package: { - name: 'test-package', - }, - }, - ], - }; + it('should call audit logger', async () => { + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + const soClient = savedObjectsClientMock.create(); - soClient.find.mockResolvedValueOnce({ total: 0, saved_objects: [], page: 1, per_page: 10 }); - soClient.get.mockRejectedValueOnce(SavedObjectsErrorHelpers.createGenericNotFoundError()); + mockedAppContextService.getInternalUserESClient.mockReturnValue(esClient); + mockedOutputService.getDefaultDataOutputId.mockResolvedValueOnce('default-output'); - soClient.create.mockResolvedValueOnce({ - id: 'my-unique-id', - type: AGENT_POLICY_SAVED_OBJECT_TYPE, - attributes: {}, - references: [], - }); + soClient.bulkGet.mockResolvedValue({ + saved_objects: [ + { + attributes: {}, + references: [], + id: 'test-agent-policy', + type: AGENT_POLICY_SAVED_OBJECT_TYPE, + }, + ], + }); - await agentPolicyService.ensurePreconfiguredAgentPolicy( - soClient, - esClient, - preconfiguredAgentPolicy - ); + await agentPolicyService.deployPolicy(soClient, 'test-agent-policy'); - expect(soClient.create).toHaveBeenCalledWith( - AGENT_POLICY_SAVED_OBJECT_TYPE, - expect.anything(), - expect.objectContaining({ id: 'my-unique-id' }) - ); + expect(mockedAuditLoggingService.writeCustomAuditLog).toHaveBeenCalledWith({ + message: `User deploying policy [id=test-agent-policy]`, }); }); }); + describe('ensurePreconfiguredAgentPolicy', () => { + it('should use preconfigured id if provided for policy', async () => { + const soClient = savedObjectsClientMock.create(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + + const preconfiguredAgentPolicy: PreconfiguredAgentPolicy = { + id: 'my-unique-id', + name: 'My Preconfigured Policy', + package_policies: [ + { + name: 'my-package-policy', + id: 'my-package-policy-id', + package: { + name: 'test-package', + }, + }, + ], + }; + + soClient.find.mockResolvedValueOnce({ total: 0, saved_objects: [], page: 1, per_page: 10 }); + soClient.get.mockRejectedValueOnce(SavedObjectsErrorHelpers.createGenericNotFoundError()); + + soClient.create.mockResolvedValueOnce({ + id: 'my-unique-id', + type: AGENT_POLICY_SAVED_OBJECT_TYPE, + attributes: {}, + references: [], + }); + + await agentPolicyService.ensurePreconfiguredAgentPolicy( + soClient, + esClient, + preconfiguredAgentPolicy + ); + + expect(soClient.create).toHaveBeenCalledWith( + AGENT_POLICY_SAVED_OBJECT_TYPE, + expect.anything(), + expect.objectContaining({ id: 'my-unique-id' }) + ); + }); + }); + describe('getInactivityTimeouts', () => { const createPolicySO = (id: string, inactivityTimeout: number) => ({ id, @@ -626,4 +829,18 @@ describe('agent policy', () => { ]); }); }); + + describe('deleteFleetServerPoliciesForPolicyId', () => { + it('should call audit logger', async () => { + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + + esClient.deleteByQuery.mockResolvedValueOnce({} as any); + + await agentPolicyService.deleteFleetServerPoliciesForPolicyId(esClient, 'test-agent-policy'); + + expect(mockedAuditLoggingService.writeCustomAuditLog).toHaveBeenCalledWith({ + message: 'User deleting policy [id=test-agent-policy]', + }); + }); + }); }); diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index 2720701f0b29c..96b8fd55e3b84 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -15,6 +15,7 @@ import type { SavedObjectsClientContract, SavedObjectsBulkUpdateResponse, } from '@kbn/core/server'; +import { SavedObjectsUtils } from '@kbn/core/server'; import type { AuthenticatedUser } from '@kbn/security-plugin/server'; import type { BulkResponseItem } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; @@ -83,6 +84,7 @@ import { normalizeKuery, escapeSearchQueryPhrase } from './saved_object'; import { appContextService } from './app_context'; import { getFullAgentPolicy } from './agent_policies'; import { validateOutputForPolicy } from './agent_policies'; +import { auditLoggingService } from './audit_logging'; const SAVED_OBJECT_TYPE = AGENT_POLICY_SAVED_OBJECT_TYPE; @@ -106,6 +108,12 @@ class AgentPolicyService { user?: AuthenticatedUser, options: { bumpRevision: boolean } = { bumpRevision: true } ): Promise { + auditLoggingService.writeCustomSoAuditLog({ + action: 'update', + id, + savedObjectType: AGENT_POLICY_SAVED_OBJECT_TYPE, + }); + const existingAgentPolicy = await this.get(soClient, id, true); if (!existingAgentPolicy) { @@ -201,8 +209,19 @@ class AgentPolicyService { soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, agentPolicy: NewAgentPolicy, - options?: { id?: string; user?: AuthenticatedUser } + options: { id?: string; user?: AuthenticatedUser } = {} ): Promise { + // Ensure an ID is provided, so we can include it in the audit logs below + if (!options.id) { + options.id = SavedObjectsUtils.generateId(); + } + + auditLoggingService.writeCustomSoAuditLog({ + action: 'create', + id: options.id, + savedObjectType: AGENT_POLICY_SAVED_OBJECT_TYPE, + }); + await this.requireUniqueName(soClient, agentPolicy); await validateOutputForPolicy(soClient, agentPolicy); @@ -270,6 +289,12 @@ class AgentPolicyService { (await packagePolicyService.findAllForAgentPolicy(soClient, id)) || []; } + auditLoggingService.writeCustomSoAuditLog({ + action: 'get', + id, + savedObjectType: AGENT_POLICY_SAVED_OBJECT_TYPE, + }); + return agentPolicy; } @@ -313,7 +338,19 @@ class AgentPolicyService { { concurrency: 50 } ); - return agentPolicies.filter((agentPolicy): agentPolicy is AgentPolicy => agentPolicy !== null); + const result = agentPolicies.filter( + (agentPolicy): agentPolicy is AgentPolicy => agentPolicy !== null + ); + + for (const agentPolicy of result) { + auditLoggingService.writeCustomSoAuditLog({ + action: 'get', + id: agentPolicy.id, + savedObjectType: AGENT_POLICY_SAVED_OBJECT_TYPE, + }); + } + + return result; } public async list( @@ -386,6 +423,14 @@ class AgentPolicyService { { concurrency: 50 } ); + for (const agentPolicy of agentPolicies) { + auditLoggingService.writeCustomSoAuditLog({ + action: 'find', + id: agentPolicy.id, + savedObjectType: AGENT_POLICY_SAVED_OBJECT_TYPE, + }); + } + return { items: agentPolicies, total: agentPoliciesSO.total, @@ -659,8 +704,14 @@ class AgentPolicyService { soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, id: string, - options?: { force?: boolean; removeFleetServerDocuments?: boolean } + options?: { force?: boolean; removeFleetServerDocuments?: boolean; user?: AuthenticatedUser } ): Promise { + auditLoggingService.writeCustomSoAuditLog({ + action: 'delete', + id, + savedObjectType: AGENT_POLICY_SAVED_OBJECT_TYPE, + }); + const agentPolicy = await this.get(soClient, id, false); if (!agentPolicy) { throw new Error('Agent policy not found'); @@ -736,6 +787,12 @@ class AgentPolicyService { return; } + for (const policyId of agentPolicyIds) { + auditLoggingService.writeCustomAuditLog({ + message: `User deploying policy [id=${policyId}]`, + }); + } + const policies = await agentPolicyService.getByIDs(soClient, agentPolicyIds); const policiesMap = keyBy(policies, 'id'); const fullPolicies = await Promise.all( @@ -835,6 +892,10 @@ class AgentPolicyService { esClient: ElasticsearchClient, agentPolicyId: string ) { + auditLoggingService.writeCustomAuditLog({ + message: `User deleting policy [id=${agentPolicyId}]`, + }); + await esClient.deleteByQuery({ index: AGENT_POLICY_INDEX, ignore_unavailable: true, diff --git a/x-pack/plugins/fleet/server/services/agent_policy_create.ts b/x-pack/plugins/fleet/server/services/agent_policy_create.ts index 5f3d6d14822c9..1f7aeb2bc985f 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy_create.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy_create.ts @@ -56,6 +56,7 @@ async function createPackagePolicy( // rollback agent policy on error await agentPolicyService.delete(soClient, esClient, agentPolicy.id, { force: true, + user: options.user, }); throw error; }); diff --git a/x-pack/plugins/fleet/server/services/agents/actions.test.ts b/x-pack/plugins/fleet/server/services/agents/actions.test.ts index 30a63e212370b..b6e72b8208b1b 100644 --- a/x-pack/plugins/fleet/server/services/agents/actions.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/actions.test.ts @@ -7,15 +7,26 @@ import { elasticsearchServiceMock } from '@kbn/core/server/mocks'; +import type { NewAgentAction } from '../../../common/types'; + import { createAppContextStartContractMock } from '../../mocks'; import { appContextService } from '../app_context'; +import { auditLoggingService } from '../audit_logging'; -import { cancelAgentAction, getAgentsByActionsIds } from './actions'; +import { + bulkCreateAgentActionResults, + bulkCreateAgentActions, + cancelAgentAction, + createAgentAction, + getAgentsByActionsIds, +} from './actions'; import { bulkUpdateAgents } from './crud'; jest.mock('./crud'); +jest.mock('../audit_logging'); -const mockedBulkUpdateAgents = bulkUpdateAgents as jest.Mock; +const mockedBulkUpdateAgents = bulkUpdateAgents as jest.MockedFunction; +const mockedAuditLoggingService = auditLoggingService as jest.Mocked; describe('Agent actions', () => { beforeEach(async () => { @@ -24,7 +35,115 @@ describe('Agent actions', () => { afterEach(() => { appContextService.stop(); + mockedAuditLoggingService.writeCustomAuditLog.mockReset(); }); + + describe('getAgentActions', () => { + it('should call audit logger', async () => { + const esClientMock = elasticsearchServiceMock.createInternalClient(); + + esClientMock.search.mockResolvedValue({ + hits: { + hits: [ + { + _source: { + action_id: 'action1', + agents: ['agent1'], + expiration: new Date().toISOString(), + type: 'UPGRADE', + }, + }, + ], + }, + } as any); + + await getAgentsByActionsIds(esClientMock, ['action1']); + + expect(mockedAuditLoggingService.writeCustomAuditLog).toHaveBeenCalledWith({ + message: `User retrieved Fleet action [id=action1]`, + }); + }); + }); + + describe('createAgentAction', () => { + it('should call audit logger', async () => { + const esClient = elasticsearchServiceMock.createInternalClient(); + esClient.search.mockResolvedValue({ + hits: { + hits: [ + { + _source: { + type: 'UPGRADE', + action_id: 'action1', + agents: ['agent1', 'agent2'], + expiration: new Date().toISOString(), + }, + }, + ], + }, + } as any); + + await createAgentAction(esClient, { + id: 'action1', + type: 'UPGRADE', + agents: ['agent1'], + }); + + expect(mockedAuditLoggingService.writeCustomAuditLog).toHaveBeenCalledWith({ + message: expect.stringMatching(/User created Fleet action/), + }); + }); + }); + + describe('bulkCreateAgentAction', () => { + it('should call audit logger', async () => { + const esClient = elasticsearchServiceMock.createInternalClient(); + + const newActions: NewAgentAction[] = [ + { + id: 'action1', + type: 'UPGRADE', + agents: ['agent1'], + }, + { + id: 'action2', + type: 'UPGRADE', + agents: ['agent2'], + }, + { + id: 'action3', + type: 'UPGRADE', + agents: ['agent3'], + }, + ]; + + await bulkCreateAgentActions(esClient, newActions); + + for (const action of newActions) { + expect(mockedAuditLoggingService.writeCustomAuditLog).toHaveBeenCalledWith({ + message: `User created Fleet action [id=${action.id}]`, + }); + } + }); + }); + + describe('bulkCreateAgentActionResults', () => { + it('should call audit logger', async () => { + const mockEsClient = elasticsearchServiceMock.createInternalClient(); + + await bulkCreateAgentActionResults(mockEsClient, [ + { + actionId: 'action1', + agentId: 'agent1', + }, + ]); + + expect(mockedAuditLoggingService.writeCustomAuditLog).toHaveBeenCalledWith({ + message: `User created Fleet action result [id=action1]`, + }); + }); + }); + describe('cancelAgentAction', () => { it('throw if the target action is not found', async () => { const esClient = elasticsearchServiceMock.createInternalClient(); @@ -114,6 +233,7 @@ describe('Agent actions', () => { ); }); }); + describe('getAgentsByActionsIds', () => { const esClientMock = elasticsearchServiceMock.createElasticsearchClient(); diff --git a/x-pack/plugins/fleet/server/services/agents/actions.ts b/x-pack/plugins/fleet/server/services/agents/actions.ts index 79d0449b60ff5..bc56b7e80e8af 100644 --- a/x-pack/plugins/fleet/server/services/agents/actions.ts +++ b/x-pack/plugins/fleet/server/services/agents/actions.ts @@ -22,6 +22,8 @@ import { } from '../../../common/constants'; import { AgentActionNotFoundError } from '../../errors'; +import { auditLoggingService } from '../audit_logging'; + import { bulkUpdateAgents } from './crud'; const ONE_MONTH_IN_MS = 2592000000; @@ -57,6 +59,10 @@ export async function createAgentAction( refresh: 'wait_for', }); + auditLoggingService.writeCustomAuditLog({ + message: `User created Fleet action [id=${actionId}]`, + }); + return { id: actionId, ...newAgentAction, @@ -105,6 +111,12 @@ export async function bulkCreateAgentActions( }), }); + for (const action of actions) { + auditLoggingService.writeCustomAuditLog({ + message: `User created Fleet action [id=${action.id}]`, + }); + } + return actions; } @@ -164,6 +176,12 @@ export async function bulkCreateAgentActionResults( ]; }); + for (const result of results) { + auditLoggingService.writeCustomAuditLog({ + message: `User created Fleet action result [id=${result.actionId}]`, + }); + } + await esClient.bulk({ index: AGENT_ACTIONS_RESULTS_INDEX, body: bulkBody, @@ -192,10 +210,20 @@ export async function getAgentActions(esClient: ElasticsearchClient, actionId: s throw new AgentActionNotFoundError('Action not found'); } - return res.hits.hits.map((hit) => ({ - ...hit._source, - id: hit._id, - })) as FleetServerAgentAction[]; + const result: FleetServerAgentAction[] = []; + + for (const hit of res.hits.hits) { + auditLoggingService.writeCustomAuditLog({ + message: `User retrieved Fleet action [id=${hit._source?.action_id}]`, + }); + + result.push({ + ...hit._source, + id: hit._id, + }); + } + + return result; } export async function getUnenrollAgentActions( @@ -227,10 +255,20 @@ export async function getUnenrollAgentActions( size: SO_SEARCH_LIMIT, }); - return res.hits.hits.map((hit) => ({ - ...hit._source, - id: hit._id, - })); + const result: FleetServerAgentAction[] = []; + + for (const hit of res.hits.hits) { + auditLoggingService.writeCustomAuditLog({ + message: `User retrieved Fleet action [id=${hit._source?.action_id}]`, + }); + + result.push({ + ...hit._source, + id: hit._id, + }); + } + + return result; } export async function cancelAgentAction(esClient: ElasticsearchClient, actionId: string) { @@ -255,6 +293,12 @@ export async function cancelAgentAction(esClient: ElasticsearchClient, actionId: throw new AgentActionNotFoundError('Action not found'); } + for (const hit of res.hits.hits) { + auditLoggingService.writeCustomAuditLog({ + message: `User retrieved Fleet action [id=${hit._source?.action_id}]}]`, + }); + } + const upgradeActions: FleetServerAgentAction[] = res.hits.hits .map((hit) => hit._source as FleetServerAgentAction) .filter( @@ -368,10 +412,20 @@ async function getAgentActionsByIds(esClient: ElasticsearchClient, actionIds: st throw new AgentActionNotFoundError('Action not found'); } - return res.hits.hits.map((hit) => ({ - ...hit._source, - id: hit._id, - })) as FleetServerAgentAction[]; + const result: FleetServerAgentAction[] = []; + + for (const hit of res.hits.hits) { + auditLoggingService.writeCustomAuditLog({ + message: `User retrieved Fleet action [id=${hit._source?.action_id}]`, + }); + + result.push({ + ...hit._source, + id: hit._id, + }); + } + + return result; } export const getAgentsByActionsIds = async ( diff --git a/x-pack/plugins/fleet/server/services/agents/crud.test.ts b/x-pack/plugins/fleet/server/services/agents/crud.test.ts index 623ca8d0039bc..47bb8028fffcd 100644 --- a/x-pack/plugins/fleet/server/services/agents/crud.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/crud.test.ts @@ -6,16 +6,28 @@ */ import { errors } from '@elastic/elasticsearch'; import type { ElasticsearchClient } from '@kbn/core/server'; -import { savedObjectsClientMock } from '@kbn/core/server/mocks'; +import { elasticsearchServiceMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; +import { AGENTS_INDEX } from '../../constants'; import type { Agent } from '../../types'; -import { getAgentsByKuery, getAgentTags } from './crud'; +import { auditLoggingService } from '../audit_logging'; +import { + closePointInTime, + getAgentsByKuery, + getAgentTags, + openPointInTime, + updateAgent, +} from './crud'; + +jest.mock('../audit_logging'); jest.mock('../../../common/services/is_agent_upgradeable', () => ({ isAgentUpgradeable: jest.fn().mockImplementation((agent: Agent) => agent.id.includes('up')), })); +const mockedAuditLoggingService = auditLoggingService as jest.Mocked; + describe('Agents CRUD test', () => { const soClientMock = savedObjectsClientMock.create(); let esClientMock: ElasticsearchClient; @@ -308,4 +320,44 @@ describe('Agents CRUD test', () => { expect(searchMock.mock.calls.at(-1)[0].sort).toEqual([{ policy_id: { order: 'desc' } }]); }); }); + + describe('update', () => { + it('should write to audit log', async () => { + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + + esClient.update.mockResolvedValueOnce({} as any); + + await updateAgent(esClient, 'test-agent-id', { tags: ['new-tag'] }); + + expect(mockedAuditLoggingService.writeCustomAuditLog).toHaveBeenCalledWith({ + message: 'User updated agent [id=test-agent-id]', + }); + }); + }); + + describe('openPointInTime', () => { + it('should call audit logger', async () => { + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + esClient.openPointInTime.mockResolvedValueOnce({ id: 'test-pit' } as any); + + await openPointInTime(esClient, AGENTS_INDEX); + + expect(mockedAuditLoggingService.writeCustomAuditLog).toHaveBeenCalledWith({ + message: `User opened point in time query [index=${AGENTS_INDEX}] [pitId=test-pit]`, + }); + }); + }); + + describe('closePointInTime', () => { + it('should call audit logger', async () => { + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + esClient.closePointInTime.mockResolvedValueOnce({} as any); + + await closePointInTime(esClient, 'test-pit'); + + expect(mockedAuditLoggingService.writeCustomAuditLog).toHaveBeenCalledWith({ + message: `User closing point in time query [pitId=test-pit]`, + }); + }); + }); }); diff --git a/x-pack/plugins/fleet/server/services/agents/crud.ts b/x-pack/plugins/fleet/server/services/agents/crud.ts index 379a656b7d519..fdce358049006 100644 --- a/x-pack/plugins/fleet/server/services/agents/crud.ts +++ b/x-pack/plugins/fleet/server/services/agents/crud.ts @@ -19,6 +19,8 @@ import { isAgentUpgradeable } from '../../../common/services'; import { AGENTS_INDEX } from '../../constants'; import { FleetError, isESClientError, AgentNotFoundError } from '../../errors'; +import { auditLoggingService } from '../audit_logging'; + import { searchHitToAgent, agentSOAttributesToFleetServerAgentDoc } from './helpers'; import { buildAgentStatusRuntimeField } from './build_status_runtime_field'; @@ -105,10 +107,19 @@ export async function openPointInTime( index, keep_alive: pitKeepAlive, }); + + auditLoggingService.writeCustomAuditLog({ + message: `User opened point in time query [index=${index}] [pitId=${pitRes.id}]`, + }); + return pitRes.id; } export async function closePointInTime(esClient: ElasticsearchClient, pitId: string) { + auditLoggingService.writeCustomAuditLog({ + message: `User closing point in time query [pitId=${pitId}]`, + }); + try { await esClient.closePointInTime({ id: pitId }); } catch (error) { @@ -465,6 +476,10 @@ export async function updateAgent( agentId: string, data: Partial ) { + auditLoggingService.writeCustomAuditLog({ + message: `User updated agent [id=${agentId}]`, + }); + await esClient.update({ id: agentId, index: AGENTS_INDEX, diff --git a/x-pack/plugins/fleet/server/services/agents/reassign.test.ts b/x-pack/plugins/fleet/server/services/agents/reassign.test.ts index 8ef9ef77b7d92..6261b3295496a 100644 --- a/x-pack/plugins/fleet/server/services/agents/reassign.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/reassign.test.ts @@ -14,49 +14,7 @@ import { createAppContextStartContractMock } from '../../mocks'; import { reassignAgent, reassignAgents } from './reassign'; import { createClientMock } from './action.mock'; -describe('reassignAgent (singular)', () => { - it('can reassign from regular agent policy to regular', async () => { - const { soClient, esClient, agentInRegularDoc, regularAgentPolicySO } = createClientMock(); - await reassignAgent(soClient, esClient, agentInRegularDoc._id, regularAgentPolicySO.id); - - // calls ES update with correct values - expect(esClient.update).toBeCalledTimes(1); - const calledWith = esClient.update.mock.calls[0]; - expect(calledWith[0]?.id).toBe(agentInRegularDoc._id); - expect((calledWith[0] as estypes.UpdateRequest)?.body?.doc).toHaveProperty( - 'policy_id', - regularAgentPolicySO.id - ); - }); - - it('cannot reassign from regular agent policy to hosted', async () => { - const { soClient, esClient, agentInRegularDoc, hostedAgentPolicySO } = createClientMock(); - await expect( - reassignAgent(soClient, esClient, agentInRegularDoc._id, hostedAgentPolicySO.id) - ).rejects.toThrowError(HostedAgentPolicyRestrictionRelatedError); - - // does not call ES update - expect(esClient.update).toBeCalledTimes(0); - }); - - it('cannot reassign from hosted agent policy', async () => { - const { soClient, esClient, agentInHostedDoc, hostedAgentPolicySO, regularAgentPolicySO } = - createClientMock(); - await expect( - reassignAgent(soClient, esClient, agentInHostedDoc._id, regularAgentPolicySO.id) - ).rejects.toThrowError(HostedAgentPolicyRestrictionRelatedError); - // does not call ES update - expect(esClient.update).toBeCalledTimes(0); - - await expect( - reassignAgent(soClient, esClient, agentInHostedDoc._id, hostedAgentPolicySO.id) - ).rejects.toThrowError(HostedAgentPolicyRestrictionRelatedError); - // does not call ES update - expect(esClient.update).toBeCalledTimes(0); - }); -}); - -describe('reassignAgents (plural)', () => { +describe('reassignAgent', () => { beforeEach(async () => { appContextService.start(createAppContextStartContractMock()); }); @@ -64,68 +22,122 @@ describe('reassignAgents (plural)', () => { afterEach(() => { appContextService.stop(); }); - it('agents in hosted policies are not updated', async () => { - const { - soClient, - esClient, - agentInRegularDoc, - agentInHostedDoc, - agentInHostedDoc2, - regularAgentPolicySO2, - } = createClientMock(); - - esClient.search.mockResponse({ - hits: { - hits: [agentInRegularDoc, agentInHostedDoc, agentInHostedDoc2], - }, - } as any); - - const idsToReassign = [agentInRegularDoc._id, agentInHostedDoc._id, agentInHostedDoc2._id]; - await reassignAgents(soClient, esClient, { agentIds: idsToReassign }, regularAgentPolicySO2.id); - - // calls ES update with correct values - const calledWith = esClient.bulk.mock.calls[0][0]; - // only 1 are regular and bulk write two line per update - expect((calledWith as estypes.BulkRequest).body?.length).toBe(2); - // @ts-expect-error - expect(calledWith.body[0].update._id).toEqual(agentInRegularDoc._id); - - // hosted policy is updated in action results with error - const calledWithActionResults = esClient.bulk.mock.calls[1][0] as estypes.BulkRequest; - // bulk write two line per create - expect(calledWithActionResults.body?.length).toBe(4); - const expectedObject = expect.objectContaining({ - '@timestamp': expect.anything(), - action_id: expect.anything(), - agent_id: 'agent-in-hosted-policy', - error: - 'Cannot reassign an agent from hosted agent policy hosted-agent-policy in Fleet because the agent policy is managed by an external orchestration solution, such as Elastic Cloud, Kubernetes, etc. Please make changes using your orchestration solution.', + describe('reassignAgent (singular)', () => { + it('can reassign from regular agent policy to regular', async () => { + const { soClient, esClient, agentInRegularDoc, regularAgentPolicySO } = createClientMock(); + await reassignAgent(soClient, esClient, agentInRegularDoc._id, regularAgentPolicySO.id); + + // calls ES update with correct values + expect(esClient.update).toBeCalledTimes(1); + const calledWith = esClient.update.mock.calls[0]; + expect(calledWith[0]?.id).toBe(agentInRegularDoc._id); + expect((calledWith[0] as estypes.UpdateRequest)?.body?.doc).toHaveProperty( + 'policy_id', + regularAgentPolicySO.id + ); + }); + + it('cannot reassign from regular agent policy to hosted', async () => { + const { soClient, esClient, agentInRegularDoc, hostedAgentPolicySO } = createClientMock(); + await expect( + reassignAgent(soClient, esClient, agentInRegularDoc._id, hostedAgentPolicySO.id) + ).rejects.toThrowError(HostedAgentPolicyRestrictionRelatedError); + + // does not call ES update + expect(esClient.update).toBeCalledTimes(0); + }); + + it('cannot reassign from hosted agent policy', async () => { + const { soClient, esClient, agentInHostedDoc, hostedAgentPolicySO, regularAgentPolicySO } = + createClientMock(); + await expect( + reassignAgent(soClient, esClient, agentInHostedDoc._id, regularAgentPolicySO.id) + ).rejects.toThrowError(HostedAgentPolicyRestrictionRelatedError); + // does not call ES update + expect(esClient.update).toBeCalledTimes(0); + + await expect( + reassignAgent(soClient, esClient, agentInHostedDoc._id, hostedAgentPolicySO.id) + ).rejects.toThrowError(HostedAgentPolicyRestrictionRelatedError); + // does not call ES update + expect(esClient.update).toBeCalledTimes(0); }); - expect(calledWithActionResults.body?.[1] as any).toEqual(expectedObject); }); - it('should report errors from ES agent update call', async () => { - const { soClient, esClient, agentInRegularDoc, regularAgentPolicySO2 } = createClientMock(); - esClient.bulk.mockResponse({ - items: [ - { - update: { - _id: agentInRegularDoc._id, - error: new Error('version conflict'), - }, + describe('reassignAgents (plural)', () => { + it('agents in hosted policies are not updated', async () => { + const { + soClient, + esClient, + agentInRegularDoc, + agentInHostedDoc, + agentInHostedDoc2, + regularAgentPolicySO2, + } = createClientMock(); + + esClient.search.mockResponse({ + hits: { + hits: [agentInRegularDoc, agentInHostedDoc, agentInHostedDoc2], }, - ], - } as any); - const idsToReassign = [agentInRegularDoc._id]; - await reassignAgents(soClient, esClient, { agentIds: idsToReassign }, regularAgentPolicySO2.id); - - const calledWithActionResults = esClient.bulk.mock.calls[1][0] as estypes.BulkRequest; - const expectedObject = expect.objectContaining({ - '@timestamp': expect.anything(), - action_id: expect.anything(), - agent_id: agentInRegularDoc._id, - error: 'version conflict', + } as any); + + const idsToReassign = [agentInRegularDoc._id, agentInHostedDoc._id, agentInHostedDoc2._id]; + await reassignAgents( + soClient, + esClient, + { agentIds: idsToReassign }, + regularAgentPolicySO2.id + ); + + // calls ES update with correct values + const calledWith = esClient.bulk.mock.calls[0][0]; + // only 1 are regular and bulk write two line per update + expect((calledWith as estypes.BulkRequest).body?.length).toBe(2); + // @ts-expect-error + expect(calledWith.body[0].update._id).toEqual(agentInRegularDoc._id); + + // hosted policy is updated in action results with error + const calledWithActionResults = esClient.bulk.mock.calls[1][0] as estypes.BulkRequest; + // bulk write two line per create + expect(calledWithActionResults.body?.length).toBe(4); + const expectedObject = expect.objectContaining({ + '@timestamp': expect.anything(), + action_id: expect.anything(), + agent_id: 'agent-in-hosted-policy', + error: + 'Cannot reassign an agent from hosted agent policy hosted-agent-policy in Fleet because the agent policy is managed by an external orchestration solution, such as Elastic Cloud, Kubernetes, etc. Please make changes using your orchestration solution.', + }); + expect(calledWithActionResults.body?.[1] as any).toEqual(expectedObject); + }); + + it('should report errors from ES agent update call', async () => { + const { soClient, esClient, agentInRegularDoc, regularAgentPolicySO2 } = createClientMock(); + esClient.bulk.mockResponse({ + items: [ + { + update: { + _id: agentInRegularDoc._id, + error: new Error('version conflict'), + }, + }, + ], + } as any); + const idsToReassign = [agentInRegularDoc._id]; + await reassignAgents( + soClient, + esClient, + { agentIds: idsToReassign }, + regularAgentPolicySO2.id + ); + + const calledWithActionResults = esClient.bulk.mock.calls[1][0] as estypes.BulkRequest; + const expectedObject = expect.objectContaining({ + '@timestamp': expect.anything(), + action_id: expect.anything(), + agent_id: agentInRegularDoc._id, + error: 'version conflict', + }); + expect(calledWithActionResults.body?.[1] as any).toEqual(expectedObject); }); - expect(calledWithActionResults.body?.[1] as any).toEqual(expectedObject); }); }); diff --git a/x-pack/plugins/fleet/server/services/agents/request_diagnostics.test.ts b/x-pack/plugins/fleet/server/services/agents/request_diagnostics.test.ts index 3c459ce178a3a..b297e4cb7c584 100644 --- a/x-pack/plugins/fleet/server/services/agents/request_diagnostics.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/request_diagnostics.test.ts @@ -12,24 +12,7 @@ import { createAppContextStartContractMock } from '../../mocks'; import { createClientMock } from './action.mock'; import { bulkRequestDiagnostics, requestDiagnostics } from './request_diagnostics'; -describe('requestDiagnostics (singular)', () => { - it('can request diagnostics for single agent', async () => { - const { esClient, agentInRegularDoc } = createClientMock(); - await requestDiagnostics(esClient, agentInRegularDoc._id); - - expect(esClient.create).toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.objectContaining({ - agents: ['agent-in-regular-policy'], - type: 'REQUEST_DIAGNOSTICS', - }), - index: '.fleet-actions', - }) - ); - }); -}); - -describe('requestDiagnostics (plural)', () => { +describe('requestDiagnostics', () => { beforeEach(async () => { appContextService.start(createAppContextStartContractMock()); }); @@ -37,46 +20,66 @@ describe('requestDiagnostics (plural)', () => { afterEach(() => { appContextService.stop(); }); - it('can request diagnostics for multiple agents', async () => { - const { soClient, esClient, agentInRegularDocNewer, agentInRegularDocNewer2 } = - createClientMock(); - const idsToAction = [agentInRegularDocNewer._id, agentInRegularDocNewer2._id]; - await bulkRequestDiagnostics(esClient, soClient, { agentIds: idsToAction }); - expect(esClient.create).toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.objectContaining({ - agents: ['agent-in-regular-policy-newer', 'agent-in-regular-policy-newer2'], - type: 'REQUEST_DIAGNOSTICS', - }), - index: '.fleet-actions', - }) - ); + describe('requestDiagnostics (singular)', () => { + it('can request diagnostics for single agent', async () => { + const { esClient, agentInRegularDoc } = createClientMock(); + await requestDiagnostics(esClient, agentInRegularDoc._id); + + expect(esClient.create).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + agents: ['agent-in-regular-policy'], + type: 'REQUEST_DIAGNOSTICS', + }), + index: '.fleet-actions', + }) + ); + }); }); - it('should report error when diagnostics for older agent', async () => { - const { soClient, esClient, agentInRegularDoc, agentInRegularDocNewer } = createClientMock(); - const idsToAction = [agentInRegularDocNewer._id, agentInRegularDoc._id]; - await bulkRequestDiagnostics(esClient, soClient, { agentIds: idsToAction }); + describe('requestDiagnostics (plural)', () => { + it('can request diagnostics for multiple agents', async () => { + const { soClient, esClient, agentInRegularDocNewer, agentInRegularDocNewer2 } = + createClientMock(); + const idsToAction = [agentInRegularDocNewer._id, agentInRegularDocNewer2._id]; + await bulkRequestDiagnostics(esClient, soClient, { agentIds: idsToAction }); + + expect(esClient.create).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + agents: ['agent-in-regular-policy-newer', 'agent-in-regular-policy-newer2'], + type: 'REQUEST_DIAGNOSTICS', + }), + index: '.fleet-actions', + }) + ); + }); + + it('should report error when diagnostics for older agent', async () => { + const { soClient, esClient, agentInRegularDoc, agentInRegularDocNewer } = createClientMock(); + const idsToAction = [agentInRegularDocNewer._id, agentInRegularDoc._id]; + await bulkRequestDiagnostics(esClient, soClient, { agentIds: idsToAction }); - expect(esClient.create).toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.objectContaining({ - agents: ['agent-in-regular-policy-newer', 'agent-in-regular-policy'], - type: 'REQUEST_DIAGNOSTICS', - }), - index: '.fleet-actions', - }) - ); - const calledWithActionResults = esClient.bulk.mock.calls[0][0] as estypes.BulkRequest; - // bulk write two line per create - expect(calledWithActionResults.body?.length).toBe(2); - const expectedObject = expect.objectContaining({ - '@timestamp': expect.anything(), - action_id: expect.anything(), - agent_id: 'agent-in-regular-policy', - error: 'Agent agent-in-regular-policy does not support request diagnostics action.', + expect(esClient.create).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + agents: ['agent-in-regular-policy-newer', 'agent-in-regular-policy'], + type: 'REQUEST_DIAGNOSTICS', + }), + index: '.fleet-actions', + }) + ); + const calledWithActionResults = esClient.bulk.mock.calls[0][0] as estypes.BulkRequest; + // bulk write two line per create + expect(calledWithActionResults.body?.length).toBe(2); + const expectedObject = expect.objectContaining({ + '@timestamp': expect.anything(), + action_id: expect.anything(), + agent_id: 'agent-in-regular-policy', + error: 'Agent agent-in-regular-policy does not support request diagnostics action.', + }); + expect(calledWithActionResults.body?.[1] as any).toEqual(expectedObject); }); - expect(calledWithActionResults.body?.[1] as any).toEqual(expectedObject); }); }); diff --git a/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts b/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts index ddf7f0a2ab8f0..4231d8dfc078d 100644 --- a/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts @@ -25,62 +25,7 @@ jest.mock('../api_keys'); const mockedInvalidateAPIKeys = invalidateAPIKeys as jest.MockedFunction; -describe('unenrollAgent (singular)', () => { - it('can unenroll from regular agent policy', async () => { - const { soClient, esClient, agentInRegularDoc } = createClientMock(); - await unenrollAgent(soClient, esClient, agentInRegularDoc._id); - - // calls ES update with correct values - expect(esClient.update).toBeCalledTimes(1); - const calledWith = esClient.update.mock.calls[0]; - expect(calledWith[0]?.id).toBe(agentInRegularDoc._id); - expect((calledWith[0] as estypes.UpdateRequest)?.body).toHaveProperty( - 'doc.unenrollment_started_at' - ); - }); - - it('cannot unenroll from hosted agent policy by default', async () => { - const { soClient, esClient, agentInHostedDoc } = createClientMock(); - await expect(unenrollAgent(soClient, esClient, agentInHostedDoc._id)).rejects.toThrowError( - HostedAgentPolicyRestrictionRelatedError - ); - // does not call ES update - expect(esClient.update).toBeCalledTimes(0); - }); - - it('cannot unenroll from hosted agent policy with revoke=true', async () => { - const { soClient, esClient, agentInHostedDoc } = createClientMock(); - await expect( - unenrollAgent(soClient, esClient, agentInHostedDoc._id, { revoke: true }) - ).rejects.toThrowError(HostedAgentPolicyRestrictionRelatedError); - // does not call ES update - expect(esClient.update).toBeCalledTimes(0); - }); - - it('can unenroll from hosted agent policy with force=true', async () => { - const { soClient, esClient, agentInHostedDoc } = createClientMock(); - await unenrollAgent(soClient, esClient, agentInHostedDoc._id, { force: true }); - // calls ES update with correct values - expect(esClient.update).toBeCalledTimes(1); - const calledWith = esClient.update.mock.calls[0]; - expect(calledWith[0]?.id).toBe(agentInHostedDoc._id); - expect((calledWith[0] as estypes.UpdateRequest)?.body).toHaveProperty( - 'doc.unenrollment_started_at' - ); - }); - - it('can unenroll from hosted agent policy with force=true and revoke=true', async () => { - const { soClient, esClient, agentInHostedDoc } = createClientMock(); - await unenrollAgent(soClient, esClient, agentInHostedDoc._id, { force: true, revoke: true }); - // calls ES update with correct values - expect(esClient.update).toBeCalledTimes(1); - const calledWith = esClient.update.mock.calls[0]; - expect(calledWith[0]?.id).toBe(agentInHostedDoc._id); - expect((calledWith[0] as estypes.UpdateRequest)?.body).toHaveProperty('doc.unenrolled_at'); - }); -}); - -describe('unenrollAgents (plural)', () => { +describe('unenroll', () => { beforeEach(async () => { appContextService.start(createAppContextStartContractMock()); }); @@ -88,320 +33,381 @@ describe('unenrollAgents (plural)', () => { afterEach(() => { appContextService.stop(); }); - it('can unenroll from an regular agent policy', async () => { - const { soClient, esClient, agentInRegularDoc, agentInRegularDoc2 } = createClientMock(); - const idsToUnenroll = [agentInRegularDoc._id, agentInRegularDoc2._id]; - await unenrollAgents(soClient, esClient, { agentIds: idsToUnenroll }); - - // calls ES update with correct values - const calledWith = esClient.bulk.mock.calls[0][0]; - const ids = (calledWith as estypes.BulkRequest)?.body - ?.filter((i: any) => i.update !== undefined) - .map((i: any) => i.update._id); - const docs = (calledWith as estypes.BulkRequest)?.body - ?.filter((i: any) => i.doc) - .map((i: any) => i.doc); - expect(ids).toEqual(idsToUnenroll); - for (const doc of docs!) { - expect(doc).toHaveProperty('unenrollment_started_at'); - } - }); - it('cannot unenroll from a hosted agent policy by default', async () => { - const { soClient, esClient, agentInHostedDoc, agentInRegularDoc, agentInRegularDoc2 } = - createClientMock(); - - const idsToUnenroll = [agentInRegularDoc._id, agentInHostedDoc._id, agentInRegularDoc2._id]; - await unenrollAgents(soClient, esClient, { agentIds: idsToUnenroll }); - - // calls ES update with correct values - const onlyRegular = [agentInRegularDoc._id, agentInRegularDoc2._id]; - const calledWith = esClient.bulk.mock.calls[0][0]; - const ids = (calledWith as estypes.BulkRequest)?.body - ?.filter((i: any) => i.update !== undefined) - .map((i: any) => i.update._id); - const docs = (calledWith as estypes.BulkRequest)?.body - ?.filter((i: any) => i.doc) - .map((i: any) => i.doc); - expect(ids).toEqual(onlyRegular); - for (const doc of docs!) { - expect(doc).toHaveProperty('unenrollment_started_at'); - } - - // hosted policy is updated in action results with error - const calledWithActionResults = esClient.bulk.mock.calls[1][0] as estypes.BulkRequest; - // bulk write two line per create - expect(calledWithActionResults.body?.length).toBe(2); - const expectedObject = expect.objectContaining({ - '@timestamp': expect.anything(), - action_id: expect.anything(), - agent_id: 'agent-in-hosted-policy', - error: - 'Cannot unenroll agent-in-hosted-policy from a hosted agent policy hosted-agent-policy in Fleet because the agent policy is managed by an external orchestration solution, such as Elastic Cloud, Kubernetes, etc. Please make changes using your orchestration solution.', - }); - expect(calledWithActionResults.body?.[1] as any).toEqual(expectedObject); - }); - it('force unenroll updates in progress unenroll actions', async () => { - const { soClient, esClient, agentInRegularDoc, agentInRegularDoc2 } = createClientMock(); - esClient.search.mockReset(); - - esClient.search.mockImplementation(async (request) => { - if (request?.index === AGENT_ACTIONS_INDEX) { - return { - hits: { - hits: [ - { - _source: { - agents: ['agent-in-regular-policy'], - action_id: 'other-action', - }, - }, - ], - }, - } as any; - } + describe('unenrollAgent (singular)', () => { + it('can unenroll from regular agent policy', async () => { + const { soClient, esClient, agentInRegularDoc } = createClientMock(); + await unenrollAgent(soClient, esClient, agentInRegularDoc._id); + + // calls ES update with correct values + expect(esClient.update).toBeCalledTimes(1); + const calledWith = esClient.update.mock.calls[0]; + expect(calledWith[0]?.id).toBe(agentInRegularDoc._id); + expect((calledWith[0] as estypes.UpdateRequest)?.body).toHaveProperty( + 'doc.unenrollment_started_at' + ); + }); - if (request?.index === AGENT_ACTIONS_RESULTS_INDEX) { - return { - hits: { - hits: [], - }, - }; - } + it('cannot unenroll from hosted agent policy by default', async () => { + const { soClient, esClient, agentInHostedDoc } = createClientMock(); + await expect(unenrollAgent(soClient, esClient, agentInHostedDoc._id)).rejects.toThrowError( + HostedAgentPolicyRestrictionRelatedError + ); + // does not call ES update + expect(esClient.update).toBeCalledTimes(0); + }); - return { hits: { hits: [agentInRegularDoc, agentInRegularDoc2] } }; + it('cannot unenroll from hosted agent policy with revoke=true', async () => { + const { soClient, esClient, agentInHostedDoc } = createClientMock(); + await expect( + unenrollAgent(soClient, esClient, agentInHostedDoc._id, { revoke: true }) + ).rejects.toThrowError(HostedAgentPolicyRestrictionRelatedError); + // does not call ES update + expect(esClient.update).toBeCalledTimes(0); }); - const idsToUnenroll = [agentInRegularDoc._id, agentInRegularDoc2._id]; - await unenrollAgents(soClient, esClient, { - agentIds: idsToUnenroll, - revoke: true, + it('can unenroll from hosted agent policy with force=true', async () => { + const { soClient, esClient, agentInHostedDoc } = createClientMock(); + await unenrollAgent(soClient, esClient, agentInHostedDoc._id, { force: true }); + // calls ES update with correct values + expect(esClient.update).toBeCalledTimes(1); + const calledWith = esClient.update.mock.calls[0]; + expect(calledWith[0]?.id).toBe(agentInHostedDoc._id); + expect((calledWith[0] as estypes.UpdateRequest)?.body).toHaveProperty( + 'doc.unenrollment_started_at' + ); }); - expect(esClient.bulk.mock.calls.length).toEqual(3); - const bulkBody = (esClient.bulk.mock.calls[2][0] as estypes.BulkRequest)?.body?.[1] as any; - expect(bulkBody.agent_id).toEqual(agentInRegularDoc._id); - expect(bulkBody.action_id).toEqual('other-action'); + it('can unenroll from hosted agent policy with force=true and revoke=true', async () => { + const { soClient, esClient, agentInHostedDoc } = createClientMock(); + await unenrollAgent(soClient, esClient, agentInHostedDoc._id, { force: true, revoke: true }); + // calls ES update with correct values + expect(esClient.update).toBeCalledTimes(1); + const calledWith = esClient.update.mock.calls[0]; + expect(calledWith[0]?.id).toBe(agentInHostedDoc._id); + expect((calledWith[0] as estypes.UpdateRequest)?.body).toHaveProperty('doc.unenrolled_at'); + }); }); - it('force unenroll should not update completed unenroll actions', async () => { - const { soClient, esClient, agentInRegularDoc, agentInRegularDoc2 } = createClientMock(); - esClient.search.mockReset(); - esClient.search.mockImplementation(async (request) => { - if (request?.index === AGENT_ACTIONS_INDEX) { - return { - hits: { - hits: [ - { - _source: { - agents: ['agent-in-regular-policy'], - action_id: 'other-action1', - }, - }, - ], - }, - } as any; + describe('unenrollAgents (plural)', () => { + it('can unenroll from an regular agent policy', async () => { + const { soClient, esClient, agentInRegularDoc, agentInRegularDoc2 } = createClientMock(); + const idsToUnenroll = [agentInRegularDoc._id, agentInRegularDoc2._id]; + await unenrollAgents(soClient, esClient, { agentIds: idsToUnenroll }); + + // calls ES update with correct values + const calledWith = esClient.bulk.mock.calls[0][0]; + const ids = (calledWith as estypes.BulkRequest)?.body + ?.filter((i: any) => i.update !== undefined) + .map((i: any) => i.update._id); + const docs = (calledWith as estypes.BulkRequest)?.body + ?.filter((i: any) => i.doc) + .map((i: any) => i.doc); + expect(ids).toEqual(idsToUnenroll); + for (const doc of docs!) { + expect(doc).toHaveProperty('unenrollment_started_at'); } - - if (request?.index === AGENT_ACTIONS_RESULTS_INDEX) { - return { - hits: { - hits: [ - { _source: { action_id: 'other-action1', agent_id: 'agent-in-regular-policy' } }, - ], - }, - }; + }); + it('cannot unenroll from a hosted agent policy by default', async () => { + const { soClient, esClient, agentInHostedDoc, agentInRegularDoc, agentInRegularDoc2 } = + createClientMock(); + + const idsToUnenroll = [agentInRegularDoc._id, agentInHostedDoc._id, agentInRegularDoc2._id]; + await unenrollAgents(soClient, esClient, { agentIds: idsToUnenroll }); + + // calls ES update with correct values + const onlyRegular = [agentInRegularDoc._id, agentInRegularDoc2._id]; + const calledWith = esClient.bulk.mock.calls[0][0]; + const ids = (calledWith as estypes.BulkRequest)?.body + ?.filter((i: any) => i.update !== undefined) + .map((i: any) => i.update._id); + const docs = (calledWith as estypes.BulkRequest)?.body + ?.filter((i: any) => i.doc) + .map((i: any) => i.doc); + expect(ids).toEqual(onlyRegular); + for (const doc of docs!) { + expect(doc).toHaveProperty('unenrollment_started_at'); } - return { hits: { hits: [agentInRegularDoc, agentInRegularDoc2] } }; + // hosted policy is updated in action results with error + const calledWithActionResults = esClient.bulk.mock.calls[1][0] as estypes.BulkRequest; + // bulk write two line per create + expect(calledWithActionResults.body?.length).toBe(2); + const expectedObject = expect.objectContaining({ + '@timestamp': expect.anything(), + action_id: expect.anything(), + agent_id: 'agent-in-hosted-policy', + error: + 'Cannot unenroll agent-in-hosted-policy from a hosted agent policy hosted-agent-policy in Fleet because the agent policy is managed by an external orchestration solution, such as Elastic Cloud, Kubernetes, etc. Please make changes using your orchestration solution.', + }); + expect(calledWithActionResults.body?.[1] as any).toEqual(expectedObject); }); - const idsToUnenroll = [agentInRegularDoc._id, agentInRegularDoc2._id]; - await unenrollAgents(soClient, esClient, { - agentIds: idsToUnenroll, - revoke: true, + it('force unenroll updates in progress unenroll actions', async () => { + const { soClient, esClient, agentInRegularDoc, agentInRegularDoc2 } = createClientMock(); + esClient.search.mockReset(); + + esClient.search.mockImplementation(async (request) => { + if (request?.index === AGENT_ACTIONS_INDEX) { + return { + hits: { + hits: [ + { + _source: { + agents: ['agent-in-regular-policy'], + action_id: 'other-action', + }, + }, + ], + }, + } as any; + } + + if (request?.index === AGENT_ACTIONS_RESULTS_INDEX) { + return { + hits: { + hits: [], + }, + }; + } + + return { hits: { hits: [agentInRegularDoc, agentInRegularDoc2] } }; + }); + + const idsToUnenroll = [agentInRegularDoc._id, agentInRegularDoc2._id]; + await unenrollAgents(soClient, esClient, { + agentIds: idsToUnenroll, + revoke: true, + }); + + expect(esClient.bulk.mock.calls.length).toEqual(3); + const bulkBody = (esClient.bulk.mock.calls[2][0] as estypes.BulkRequest)?.body?.[1] as any; + expect(bulkBody.agent_id).toEqual(agentInRegularDoc._id); + expect(bulkBody.action_id).toEqual('other-action'); }); - // agent and force unenroll results updated, no other action results - expect(esClient.bulk.mock.calls.length).toEqual(2); - }); + it('force unenroll should not update completed unenroll actions', async () => { + const { soClient, esClient, agentInRegularDoc, agentInRegularDoc2 } = createClientMock(); + esClient.search.mockReset(); + esClient.search.mockImplementation(async (request) => { + if (request?.index === AGENT_ACTIONS_INDEX) { + return { + hits: { + hits: [ + { + _source: { + agents: ['agent-in-regular-policy'], + action_id: 'other-action1', + }, + }, + ], + }, + } as any; + } + + if (request?.index === AGENT_ACTIONS_RESULTS_INDEX) { + return { + hits: { + hits: [ + { _source: { action_id: 'other-action1', agent_id: 'agent-in-regular-policy' } }, + ], + }, + }; + } + + return { hits: { hits: [agentInRegularDoc, agentInRegularDoc2] } }; + }); + + const idsToUnenroll = [agentInRegularDoc._id, agentInRegularDoc2._id]; + await unenrollAgents(soClient, esClient, { + agentIds: idsToUnenroll, + revoke: true, + }); + + // agent and force unenroll results updated, no other action results + expect(esClient.bulk.mock.calls.length).toEqual(2); + }); + + it('cannot unenroll from a hosted agent policy with revoke=true', async () => { + const { soClient, esClient, agentInHostedDoc, agentInRegularDoc, agentInRegularDoc2 } = + createClientMock(); + + const idsToUnenroll = [agentInRegularDoc._id, agentInHostedDoc._id, agentInRegularDoc2._id]; + + const unenrolledResponse = await unenrollAgents(soClient, esClient, { + agentIds: idsToUnenroll, + revoke: true, + }); + expect(unenrolledResponse.actionId).toBeDefined(); + + // calls ES update with correct values + const onlyRegular = [agentInRegularDoc._id, agentInRegularDoc2._id]; + const calledWith = esClient.bulk.mock.calls[0][0]; + const ids = (calledWith as estypes.BulkRequest)?.body + ?.filter((i: any) => i.update !== undefined) + .map((i: any) => i.update._id); + const docs = (calledWith as estypes.BulkRequest)?.body + ?.filter((i: any) => i.doc) + .map((i: any) => i.doc); + expect(ids).toEqual(onlyRegular); + for (const doc of docs!) { + expect(doc).toHaveProperty('unenrolled_at'); + } - it('cannot unenroll from a hosted agent policy with revoke=true', async () => { - const { soClient, esClient, agentInHostedDoc, agentInRegularDoc, agentInRegularDoc2 } = - createClientMock(); + const errorResults = esClient.bulk.mock.calls[2][0]; + const errorIds = (errorResults as estypes.BulkRequest)?.body + ?.filter((i: any) => i.agent_id) + .map((i: any) => i.agent_id); + expect(errorIds).toEqual([agentInHostedDoc._id]); - const idsToUnenroll = [agentInRegularDoc._id, agentInHostedDoc._id, agentInRegularDoc2._id]; + const actionResults = esClient.bulk.mock.calls[1][0]; + const resultIds = (actionResults as estypes.BulkRequest)?.body + ?.filter((i: any) => i.agent_id) + .map((i: any) => i.agent_id); + expect(resultIds).toEqual(onlyRegular); - const unenrolledResponse = await unenrollAgents(soClient, esClient, { - agentIds: idsToUnenroll, - revoke: true, + const action = esClient.create.mock.calls[0][0] as any; + expect(action.body.type).toEqual('FORCE_UNENROLL'); }); - expect(unenrolledResponse.actionId).toBeDefined(); - - // calls ES update with correct values - const onlyRegular = [agentInRegularDoc._id, agentInRegularDoc2._id]; - const calledWith = esClient.bulk.mock.calls[0][0]; - const ids = (calledWith as estypes.BulkRequest)?.body - ?.filter((i: any) => i.update !== undefined) - .map((i: any) => i.update._id); - const docs = (calledWith as estypes.BulkRequest)?.body - ?.filter((i: any) => i.doc) - .map((i: any) => i.doc); - expect(ids).toEqual(onlyRegular); - for (const doc of docs!) { - expect(doc).toHaveProperty('unenrolled_at'); - } - - const errorResults = esClient.bulk.mock.calls[2][0]; - const errorIds = (errorResults as estypes.BulkRequest)?.body - ?.filter((i: any) => i.agent_id) - .map((i: any) => i.agent_id); - expect(errorIds).toEqual([agentInHostedDoc._id]); - - const actionResults = esClient.bulk.mock.calls[1][0]; - const resultIds = (actionResults as estypes.BulkRequest)?.body - ?.filter((i: any) => i.agent_id) - .map((i: any) => i.agent_id); - expect(resultIds).toEqual(onlyRegular); - - const action = esClient.create.mock.calls[0][0] as any; - expect(action.body.type).toEqual('FORCE_UNENROLL'); - }); - it('can unenroll from hosted agent policy with force=true', async () => { - const { soClient, esClient, agentInHostedDoc, agentInRegularDoc, agentInRegularDoc2 } = - createClientMock(); - const idsToUnenroll = [agentInRegularDoc._id, agentInHostedDoc._id, agentInRegularDoc2._id]; - await unenrollAgents(soClient, esClient, { agentIds: idsToUnenroll, force: true }); - - // calls ES update with correct values - const calledWith = esClient.bulk.mock.calls[0][0]; - const ids = (calledWith as estypes.BulkRequest)?.body - ?.filter((i: any) => i.update !== undefined) - .map((i: any) => i.update._id); - const docs = (calledWith as estypes.BulkRequest)?.body - ?.filter((i: any) => i.doc) - .map((i: any) => i.doc); - expect(ids).toEqual(idsToUnenroll); - for (const doc of docs!) { - expect(doc).toHaveProperty('unenrollment_started_at'); - } - }); + it('can unenroll from hosted agent policy with force=true', async () => { + const { soClient, esClient, agentInHostedDoc, agentInRegularDoc, agentInRegularDoc2 } = + createClientMock(); + const idsToUnenroll = [agentInRegularDoc._id, agentInHostedDoc._id, agentInRegularDoc2._id]; + await unenrollAgents(soClient, esClient, { agentIds: idsToUnenroll, force: true }); + + // calls ES update with correct values + const calledWith = esClient.bulk.mock.calls[0][0]; + const ids = (calledWith as estypes.BulkRequest)?.body + ?.filter((i: any) => i.update !== undefined) + .map((i: any) => i.update._id); + const docs = (calledWith as estypes.BulkRequest)?.body + ?.filter((i: any) => i.doc) + .map((i: any) => i.doc); + expect(ids).toEqual(idsToUnenroll); + for (const doc of docs!) { + expect(doc).toHaveProperty('unenrollment_started_at'); + } + }); - it('can unenroll from hosted agent policy with force=true and revoke=true', async () => { - const { soClient, esClient, agentInHostedDoc, agentInRegularDoc, agentInRegularDoc2 } = - createClientMock(); + it('can unenroll from hosted agent policy with force=true and revoke=true', async () => { + const { soClient, esClient, agentInHostedDoc, agentInRegularDoc, agentInRegularDoc2 } = + createClientMock(); + + const idsToUnenroll = [agentInRegularDoc._id, agentInHostedDoc._id, agentInRegularDoc2._id]; + + const unenrolledResponse = await unenrollAgents(soClient, esClient, { + agentIds: idsToUnenroll, + revoke: true, + force: true, + }); + + expect(unenrolledResponse.actionId).toBeDefined(); + + // calls ES update with correct values + const calledWith = esClient.bulk.mock.calls[0][0]; + const ids = (calledWith as estypes.BulkRequest)?.body + ?.filter((i: any) => i.update !== undefined) + .map((i: any) => i.update._id); + const docs = (calledWith as estypes.BulkRequest)?.body + ?.filter((i: any) => i.doc) + .map((i: any) => i.doc); + expect(ids).toEqual(idsToUnenroll); + for (const doc of docs!) { + expect(doc).toHaveProperty('unenrolled_at'); + } - const idsToUnenroll = [agentInRegularDoc._id, agentInHostedDoc._id, agentInRegularDoc2._id]; + const actionResults = esClient.bulk.mock.calls[1][0]; + const resultIds = (actionResults as estypes.BulkRequest)?.body + ?.filter((i: any) => i.agent_id) + .map((i: any) => i.agent_id); + expect(resultIds).toEqual(idsToUnenroll); - const unenrolledResponse = await unenrollAgents(soClient, esClient, { - agentIds: idsToUnenroll, - revoke: true, - force: true, + const action = esClient.create.mock.calls[0][0] as any; + expect(action.body.type).toEqual('FORCE_UNENROLL'); }); - - expect(unenrolledResponse.actionId).toBeDefined(); - - // calls ES update with correct values - const calledWith = esClient.bulk.mock.calls[0][0]; - const ids = (calledWith as estypes.BulkRequest)?.body - ?.filter((i: any) => i.update !== undefined) - .map((i: any) => i.update._id); - const docs = (calledWith as estypes.BulkRequest)?.body - ?.filter((i: any) => i.doc) - .map((i: any) => i.doc); - expect(ids).toEqual(idsToUnenroll); - for (const doc of docs!) { - expect(doc).toHaveProperty('unenrolled_at'); - } - - const actionResults = esClient.bulk.mock.calls[1][0]; - const resultIds = (actionResults as estypes.BulkRequest)?.body - ?.filter((i: any) => i.agent_id) - .map((i: any) => i.agent_id); - expect(resultIds).toEqual(idsToUnenroll); - - const action = esClient.create.mock.calls[0][0] as any; - expect(action.body.type).toEqual('FORCE_UNENROLL'); }); -}); -describe('invalidateAPIKeysForAgents', () => { - beforeEach(() => { - mockedInvalidateAPIKeys.mockReset(); - }); - it('revoke all the agents API keys', async () => { - await invalidateAPIKeysForAgents([ - { - id: 'agent1', - default_api_key_id: 'defaultApiKey1', - access_api_key_id: 'accessApiKey1', - default_api_key_history: [ - { - id: 'defaultApiKeyHistory1', - }, - { - id: 'defaultApiKeyHistory2', - }, - ], - outputs: { - output1: { - api_key_id: 'outputApiKey1', - to_retire_api_key_ids: [{ id: 'outputApiKeyRetire1' }, { id: 'outputApiKeyRetire2' }], - }, - output2: { - api_key_id: 'outputApiKey2', - }, - output3: { - api_key_id: 'outputApiKey3', - to_retire_api_key_ids: [ - // Somes Fleet Server agents don't have an id here (probably a bug) - { retired_at: 'foo' }, - ], + describe('invalidateAPIKeysForAgents', () => { + beforeEach(() => { + mockedInvalidateAPIKeys.mockReset(); + }); + it('revoke all the agents API keys', async () => { + await invalidateAPIKeysForAgents([ + { + id: 'agent1', + default_api_key_id: 'defaultApiKey1', + access_api_key_id: 'accessApiKey1', + default_api_key_history: [ + { + id: 'defaultApiKeyHistory1', + }, + { + id: 'defaultApiKeyHistory2', + }, + ], + outputs: { + output1: { + api_key_id: 'outputApiKey1', + to_retire_api_key_ids: [{ id: 'outputApiKeyRetire1' }, { id: 'outputApiKeyRetire2' }], + }, + output2: { + api_key_id: 'outputApiKey2', + }, + output3: { + api_key_id: 'outputApiKey3', + to_retire_api_key_ids: [ + // Somes Fleet Server agents don't have an id here (probably a bug) + { retired_at: 'foo' }, + ], + }, }, - }, - } as any, - ]); - - expect(mockedInvalidateAPIKeys).toBeCalledTimes(1); - expect(mockedInvalidateAPIKeys).toBeCalledWith([ - 'accessApiKey1', - 'defaultApiKey1', - 'defaultApiKeyHistory1', - 'defaultApiKeyHistory2', - 'outputApiKey1', - 'outputApiKeyRetire1', - 'outputApiKeyRetire2', - 'outputApiKey2', - 'outputApiKey3', - ]); + } as any, + ]); + + expect(mockedInvalidateAPIKeys).toBeCalledTimes(1); + expect(mockedInvalidateAPIKeys).toBeCalledWith([ + 'accessApiKey1', + 'defaultApiKey1', + 'defaultApiKeyHistory1', + 'defaultApiKeyHistory2', + 'outputApiKey1', + 'outputApiKeyRetire1', + 'outputApiKeyRetire2', + 'outputApiKey2', + 'outputApiKey3', + ]); + }); }); -}); -describe('isAgentUnenrolled', () => { - it('should return true if revoke and unenrolled_at set', () => { - expect(isAgentUnenrolled({ unenrolled_at: '2022-09-21' } as Agent, true)).toBe(true); - }); + describe('isAgentUnenrolled', () => { + it('should return true if revoke and unenrolled_at set', () => { + expect(isAgentUnenrolled({ unenrolled_at: '2022-09-21' } as Agent, true)).toBe(true); + }); - it('should return false if revoke and unenrolled_at null', () => { - expect( - isAgentUnenrolled({ unenrolled_at: null, unenrollment_started_at: '2022-09-21' } as any, true) - ).toBe(false); - }); + it('should return false if revoke and unenrolled_at null', () => { + expect( + isAgentUnenrolled( + { unenrolled_at: null, unenrollment_started_at: '2022-09-21' } as any, + true + ) + ).toBe(false); + }); - it('should return true if unenrolled_at set', () => { - expect(isAgentUnenrolled({ unenrolled_at: '2022-09-21' } as Agent)).toBe(true); - }); + it('should return true if unenrolled_at set', () => { + expect(isAgentUnenrolled({ unenrolled_at: '2022-09-21' } as Agent)).toBe(true); + }); - it('should return true if unenrollment_started_at set and unenrolled_at null', () => { - expect( - isAgentUnenrolled({ unenrolled_at: null, unenrollment_started_at: '2022-09-21' } as any) - ).toBe(true); - }); + it('should return true if unenrollment_started_at set and unenrolled_at null', () => { + expect( + isAgentUnenrolled({ unenrolled_at: null, unenrollment_started_at: '2022-09-21' } as any) + ).toBe(true); + }); - it('should return false if unenrollment_started_at null and unenrolled_at null', () => { - expect(isAgentUnenrolled({ unenrolled_at: null, unenrollment_started_at: null } as any)).toBe( - false - ); + it('should return false if unenrollment_started_at null and unenrolled_at null', () => { + expect(isAgentUnenrolled({ unenrolled_at: null, unenrollment_started_at: null } as any)).toBe( + false + ); + }); }); }); diff --git a/x-pack/plugins/fleet/server/services/agents/update_agent_tags.test.ts b/x-pack/plugins/fleet/server/services/agents/update_agent_tags.test.ts index ab407705007db..2dc239e01d82a 100644 --- a/x-pack/plugins/fleet/server/services/agents/update_agent_tags.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/update_agent_tags.test.ts @@ -24,6 +24,7 @@ jest.mock('../app_context', () => { }, }; }); +jest.mock('../audit_logging'); jest.mock('../agent_policy', () => { return { diff --git a/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key.test.ts b/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key.test.ts new file mode 100644 index 0000000000000..bc58941e4c295 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key.test.ts @@ -0,0 +1,102 @@ +/* + * 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 { elasticsearchServiceMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; + +import { ENROLLMENT_API_KEYS_INDEX } from '../../constants'; + +import { agentPolicyService } from '../agent_policy'; +import { auditLoggingService } from '../audit_logging'; +import { appContextService } from '../app_context'; + +import { deleteEnrollmentApiKey, generateEnrollmentAPIKey } from './enrollment_api_key'; + +jest.mock('../audit_logging'); +jest.mock('../agent_policy'); +jest.mock('../app_context'); + +jest.mock('uuid', () => { + return { + v4: () => 'mock-uuid', + }; +}); + +const mockedAgentPolicyService = agentPolicyService as jest.Mocked; +const mockedAuditLoggingService = auditLoggingService as jest.Mocked; +const mockedAppContextService = appContextService as jest.Mocked; + +describe('enrollment api keys', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('generateEnrollmentAPIKey', () => { + it('should call audit logger', async () => { + const soClient = savedObjectsClientMock.create(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + + esClient.create.mockResolvedValue({ + _id: 'test-enrollment-api-key-id', + } as any); + + esClient.security.createApiKey.mockResolvedValue({ + api_key: 'test-api-key-value', + id: 'test-api-key-id', + } as any); + + mockedAgentPolicyService.get.mockResolvedValue({ + id: 'test-agent-policy', + } as any); + + await generateEnrollmentAPIKey(soClient, esClient, { + name: 'test-api-key', + expiration: '7d', + agentPolicyId: 'test-agent-policy', + forceRecreate: true, + }); + + expect(mockedAuditLoggingService.writeCustomAuditLog).toHaveBeenCalledWith({ + message: + 'User creating enrollment API key [name=test-api-key (mock-uuid)] [policy_id=test-agent-policy]', + }); + }); + }); + + describe('deleteEnrollmentApiKey', () => { + it('should call audit logger', async () => { + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + + esClient.update.mockResolvedValue({} as any); + + esClient.get.mockResolvedValue({ + _id: 'test-id', + _index: ENROLLMENT_API_KEYS_INDEX, + _source: { + active: true, + created_at: new Date().toISOString(), + api_key_id: 'test-enrollment-api-key-id', + }, + found: true, + }); + + mockedAppContextService.getSecurity.mockReturnValue({ + authc: { + apiKeys: { + invalidateAsInternalUser: jest.fn().mockResolvedValue({}), + }, + }, + } as any); + + await deleteEnrollmentApiKey(esClient, 'test-enrollment-api-key-id'); + + expect(auditLoggingService.writeCustomAuditLog).toHaveBeenCalledWith({ + message: + 'User deleting enrollment API key [id=test-id] [api_key_id=test-enrollment-api-key-id]', + }); + }); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key.ts b/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key.ts index dadb41a27aac1..ac7087c95296a 100644 --- a/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key.ts +++ b/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key.ts @@ -21,6 +21,8 @@ import { ENROLLMENT_API_KEYS_INDEX } from '../../constants'; import { agentPolicyService } from '../agent_policy'; import { escapeSearchQueryPhrase } from '../saved_object'; +import { auditLoggingService } from '../audit_logging'; + import { invalidateAPIKeys } from './security'; const uuidRegex = @@ -106,6 +108,10 @@ export async function deleteEnrollmentApiKey( ) { const enrollmentApiKey = await getEnrollmentAPIKey(esClient, id); + auditLoggingService.writeCustomAuditLog({ + message: `User deleting enrollment API key [id=${enrollmentApiKey.id}] [api_key_id=${enrollmentApiKey.api_key_id}]`, + }); + await invalidateAPIKeys([enrollmentApiKey.api_key_id]); if (forceDelete) { @@ -208,6 +214,10 @@ export async function generateEnrollmentAPIKey( const name = providedKeyName ? `${providedKeyName} (${id})` : id; + auditLoggingService.writeCustomAuditLog({ + message: `User creating enrollment API key [name=${name}] [policy_id=${agentPolicyId}]`, + }); + const key = await esClient.security .createApiKey({ body: { diff --git a/x-pack/plugins/fleet/server/services/app_context.ts b/x-pack/plugins/fleet/server/services/app_context.ts index 71c09621c59a0..3ea1f4e464e4a 100644 --- a/x-pack/plugins/fleet/server/services/app_context.ts +++ b/x-pack/plugins/fleet/server/services/app_context.ts @@ -120,6 +120,10 @@ class AppContextService { return this.securityStart!; } + public getSecuritySetup() { + return this.securitySetup!; + } + public getSecurityLicense() { return this.securitySetup!.license; } diff --git a/x-pack/plugins/fleet/server/services/audit_logging.ts b/x-pack/plugins/fleet/server/services/audit_logging.ts new file mode 100644 index 0000000000000..a9549ade160d1 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/audit_logging.ts @@ -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 type { AuditLogger } from '@kbn/security-plugin/server'; + +import { appContextService } from './app_context'; +import { getRequestStore } from './request_store'; + +class AuditLoggingService { + /** + * Write a custom audit log record. If a current request is available, the log will include + * user/session data. If not, an unscoped audit logger will be used. + */ + public writeCustomAuditLog(...args: Parameters) { + const securitySetup = appContextService.getSecuritySetup(); + let auditLogger: AuditLogger | undefined; + + const request = getRequestStore().getStore(); + + if (request) { + auditLogger = securitySetup.audit.asScoped(request); + } else { + auditLogger = securitySetup.audit.withoutRequest; + } + + auditLogger.log(...args); + } + + /** + * Helper method for writing saved object related audit logs. Since Fleet + * uses an internal SO client to support its custom RBAC model around Fleet/Integrations + * permissions, we need to implement our own audit logging for saved objects that use the + * internal client. This helper reduces the boilerplate around audit logging in those cases. + * + * @example + * ```ts + * auditLoggingService.writeCustomSoAuditLog({ + * action: 'find', + * id: 'some-id-123', + * savedObjectType: PACKAGE_POLICY_SAVED_OBJECT_TYPE + * }); + * ``` + */ + public writeCustomSoAuditLog({ + action, + id, + savedObjectType, + }: { + action: 'find' | 'get' | 'create' | 'update' | 'delete'; + id: string; + savedObjectType: string; + }) { + this.writeCustomAuditLog({ + message: `User ${ + action === 'find' || action === 'get' ? 'has accessed' : 'is accessing' + } ${savedObjectType} [id=${id}]`, + event: { + action: `saved_object_${action}`, + category: ['database'], + outcome: 'unknown', + type: ['access'], + }, + kibana: { + saved_object: { + id, + type: savedObjectType, + }, + }, + }); + } +} + +export const auditLoggingService = new AuditLoggingService(); diff --git a/x-pack/plugins/fleet/server/services/download_source.test.ts b/x-pack/plugins/fleet/server/services/download_source.test.ts index 209a2f0313a0c..500a879cf2c82 100644 --- a/x-pack/plugins/fleet/server/services/download_source.test.ts +++ b/x-pack/plugins/fleet/server/services/download_source.test.ts @@ -7,6 +7,8 @@ import { savedObjectsClientMock } from '@kbn/core/server/mocks'; +import { securityMock } from '@kbn/security-plugin/server/mocks'; + import type { DownloadSourceAttributes } from '../types'; import { DOWNLOAD_SOURCE_SAVED_OBJECT_TYPE } from '../constants'; @@ -19,6 +21,10 @@ jest.mock('./app_context'); jest.mock('./agent_policy'); const mockedAppContextService = appContextService as jest.Mocked; +mockedAppContextService.getSecuritySetup.mockImplementation(() => ({ + ...securityMock.createSetup(), +})); + const mockedAgentPolicyService = agentPolicyService as jest.Mocked; function mockDownloadSourceSO(id: string, attributes: any = {}) { diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/default_settings.test.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/default_settings.test.ts index e3d959682727e..a437d5520ffd7 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/default_settings.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/default_settings.test.ts @@ -8,6 +8,8 @@ import { loggerMock } from '@kbn/logging-mocks'; import type { Logger } from '@kbn/core/server'; +import { securityMock } from '@kbn/security-plugin/server/mocks'; + import { appContextService } from '../../../app_context'; import { buildDefaultSettings } from './default_settings'; @@ -15,6 +17,10 @@ import { buildDefaultSettings } from './default_settings'; jest.mock('../../../app_context'); const mockedAppContextService = appContextService as jest.Mocked; +mockedAppContextService.getSecuritySetup.mockImplementation(() => ({ + ...securityMock.createSetup(), +})); + let mockedLogger: jest.Mocked; describe('buildDefaultSettings', () => { beforeEach(() => { diff --git a/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts b/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts index 596ad94a067d8..7da483d0794a2 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts @@ -48,6 +48,8 @@ import { saveArchiveEntries } from '../archive/storage'; import { ConcurrentInstallOperationError } from '../../../errors'; import { appContextService, packagePolicyService } from '../..'; +import { auditLoggingService } from '../../audit_logging'; + import { createInstallation, restartInstallation, @@ -289,6 +291,12 @@ export async function _installPackage({ }) ); + auditLoggingService.writeCustomSoAuditLog({ + action: 'update', + id: pkgName, + savedObjectType: PACKAGES_SAVED_OBJECT_TYPE, + }); + const updatedPackage = await withPackageSpan('Update install status', () => savedObjectsClient.update(PACKAGES_SAVED_OBJECT_TYPE, pkgName, { version: pkgVersion, diff --git a/x-pack/plugins/fleet/server/services/epm/packages/get.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/get.test.ts index 9a03fd720bc10..e4cd54f90973d 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/get.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/get.test.ts @@ -5,8 +5,6 @@ * 2.0. */ -jest.mock('../registry'); - import type { SavedObjectsClientContract, SavedObjectsFindResult } from '@kbn/core/server'; import { SavedObjectsErrorHelpers } from '@kbn/core/server'; @@ -15,25 +13,39 @@ import { savedObjectsClientMock } from '@kbn/core/server/mocks'; import { PACKAGES_SAVED_OBJECT_TYPE, PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '../../../../common'; import type { PackagePolicySOAttributes, RegistryPackage } from '../../../../common/types'; -import * as Registry from '../registry'; - import { createAppContextStartContractMock } from '../../../mocks'; import { appContextService } from '../../app_context'; - import { PackageNotFoundError } from '../../../errors'; import { getSettings } from '../../settings'; +import { auditLoggingService } from '../../audit_logging'; + +import * as Registry from '../registry'; import { getPackageInfo, getPackages, getPackageUsageStats } from './get'; +jest.mock('../registry'); +jest.mock('../../settings'); +jest.mock('../../audit_logging'); + const MockRegistry = jest.mocked(Registry); -jest.mock('../../settings'); +const mockedAuditLoggingService = auditLoggingService as jest.Mocked; const mockGetSettings = getSettings as jest.Mock; mockGetSettings.mockResolvedValue({ prerelease_integrations_enabled: true }); describe('When using EPM `get` services', () => { + beforeEach(() => { + const mockContract = createAppContextStartContractMock(); + appContextService.start(mockContract); + }); + + afterEach(() => { + appContextService.stop(); + jest.clearAllMocks(); + }); + describe('and invoking getPackageUsageStats()', () => { let soClient: jest.Mocked; @@ -188,9 +200,6 @@ describe('When using EPM `get` services', () => { describe('getPackages', () => { beforeEach(() => { - const mockContract = createAppContextStartContractMock(); - appContextService.start(mockContract); - jest.clearAllMocks(); MockRegistry.fetchList.mockResolvedValue([ { name: 'nginx', @@ -291,6 +300,45 @@ owner: elastic`, { id: 'nginx', name: 'nginx', title: 'Nginx', version: '1.0.0' }, ]); }); + + it('should call audit logger', async () => { + const soClient = savedObjectsClientMock.create(); + + soClient.find.mockResolvedValue({ + saved_objects: [ + { + id: 'elasticsearch', + attributes: { + name: 'elasticsearch', + version: '0.0.1', + install_source: 'upload', + install_version: '0.0.1', + }, + score: 0, + type: PACKAGES_SAVED_OBJECT_TYPE, + references: [], + }, + ], + total: 1, + per_page: 10, + page: 1, + }); + + soClient.get.mockResolvedValue({ + id: 'elasticsearch', + attributes: {}, + references: [], + type: PACKAGES_SAVED_OBJECT_TYPE, + }); + + await getPackages({ savedObjectsClient: soClient }); + + expect(mockedAuditLoggingService.writeCustomSoAuditLog).toHaveBeenCalledWith({ + action: 'get', + id: 'elasticsearch', + savedObjectType: PACKAGES_SAVED_OBJECT_TYPE, + }); + }); }); describe('getPackageInfo', () => { diff --git a/x-pack/plugins/fleet/server/services/epm/packages/get.ts b/x-pack/plugins/fleet/server/services/epm/packages/get.ts index 0cfcee18900c8..351128af7f4bf 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/get.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/get.ts @@ -41,6 +41,8 @@ import { getEsPackage } from '../archive/storage'; import { getArchivePackage } from '../archive'; import { normalizeKuery } from '../../saved_object'; +import { auditLoggingService } from '../../audit_logging'; + import { createInstallableFrom } from '.'; export type { SearchParams } from '../registry'; @@ -114,6 +116,14 @@ export async function getPackages( .concat(uploadedPackagesNotInRegistry as Installable) .sort(sortByName); + for (const pkg of packageList) { + auditLoggingService.writeCustomSoAuditLog({ + action: 'get', + id: pkg.id, + savedObjectType: PACKAGES_SAVED_OBJECT_TYPE, + }); + } + if (!excludeInstallStatus) { return packageList; } @@ -154,18 +164,39 @@ export async function getLimitedPackages(options: { }); }) ); - return installedPackagesInfo.filter(isPackageLimited).map((pkgInfo) => pkgInfo.name); + + const packages = installedPackagesInfo.filter(isPackageLimited).map((pkgInfo) => pkgInfo.name); + + for (const pkg of installedPackages) { + auditLoggingService.writeCustomSoAuditLog({ + action: 'find', + id: pkg.id, + savedObjectType: PACKAGES_SAVED_OBJECT_TYPE, + }); + } + + return packages; } export async function getPackageSavedObjects( savedObjectsClient: SavedObjectsClientContract, options?: Omit ) { - return savedObjectsClient.find({ + const result = await savedObjectsClient.find({ ...(options || {}), type: PACKAGES_SAVED_OBJECT_TYPE, perPage: SO_SEARCH_LIMIT, }); + + for (const savedObject of result.saved_objects) { + auditLoggingService.writeCustomSoAuditLog({ + action: 'find', + id: savedObject.id, + savedObjectType: PACKAGES_SAVED_OBJECT_TYPE, + }); + } + + return result; } export const getInstallations = getPackageSavedObjects; @@ -267,6 +298,14 @@ export const getPackageUsageStats = async ({ filter, }); + for (const packagePolicy of packagePolicies.saved_objects) { + auditLoggingService.writeCustomSoAuditLog({ + action: 'find', + id: packagePolicy.id, + savedObjectType: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + }); + } + for (let index = 0, total = packagePolicies.saved_objects.length; index < total; index++) { agentPolicyCount.add(packagePolicies.saved_objects[index].attributes.policy_id); } @@ -375,10 +414,24 @@ export async function getInstallationObject(options: { logger?: Logger; }) { const { savedObjectsClient, pkgName, logger } = options; - return savedObjectsClient.get(PACKAGES_SAVED_OBJECT_TYPE, pkgName).catch((e) => { - logger?.error(e); - return undefined; + const installation = await savedObjectsClient + .get(PACKAGES_SAVED_OBJECT_TYPE, pkgName) + .catch((e) => { + logger?.error(e); + return undefined; + }); + + if (!installation) { + return; + } + + auditLoggingService.writeCustomSoAuditLog({ + action: 'find', + id: installation.id, + savedObjectType: PACKAGES_SAVED_OBJECT_TYPE, }); + + return installation; } export async function getInstallationObjects(options: { @@ -390,7 +443,17 @@ export async function getInstallationObjects(options: { pkgNames.map((pkgName) => ({ id: pkgName, type: PACKAGES_SAVED_OBJECT_TYPE })) ); - return res.saved_objects.filter((so) => so?.attributes); + const installations = res.saved_objects.filter((so) => so?.attributes); + + for (const installation of installations) { + auditLoggingService.writeCustomSoAuditLog({ + action: 'find', + id: installation.id, + savedObjectType: PACKAGES_SAVED_OBJECT_TYPE, + }); + } + + return installations; } export async function getInstallation(options: { diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/install.test.ts index 5466c61313d97..99c711518ff2e 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install.test.ts @@ -6,22 +6,24 @@ */ import { savedObjectsClientMock } from '@kbn/core/server/mocks'; - -import type { ElasticsearchClient } from '@kbn/core/server'; - import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common/constants'; +import type { ElasticsearchClient } from '@kbn/core/server'; -import * as Registry from '../registry'; +import type { InstallablePackage } from '../../../../common'; +import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../../common'; import { sendTelemetryEvents } from '../../upgrade_sender'; - import { licenseService } from '../../license'; +import { auditLoggingService } from '../../audit_logging'; + +import * as Registry from '../registry'; -import { installPackage } from './install'; +import { createInstallation, installPackage } from './install'; import * as install from './_install_package'; -import * as obj from '.'; import { getBundledPackages } from './bundled_packages'; +import * as obj from '.'; + jest.mock('../../app_context', () => { return { appContextService: { @@ -66,8 +68,59 @@ jest.mock('../archive', () => { deleteVerificationResult: jest.fn(), }; }); +jest.mock('../../audit_logging'); const mockGetBundledPackages = getBundledPackages as jest.MockedFunction; +const mockedAuditLoggingService = auditLoggingService as jest.Mocked; + +describe('createInstallation', () => { + const soClient = savedObjectsClientMock.create(); + + const packageInfo: InstallablePackage = { + name: 'test-package', + version: '1.0.0', + format_version: '1.0.0', + title: 'Test Package', + description: 'A package for testing', + owner: { + github: 'elastic', + }, + }; + + describe('installSource: registry', () => { + it('should call audit logger', async () => { + await createInstallation({ + savedObjectsClient: soClient, + packageInfo, + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + }); + + expect(mockedAuditLoggingService.writeCustomSoAuditLog).toHaveBeenCalledWith({ + action: 'create', + id: 'test-package', + savedObjectType: PACKAGES_SAVED_OBJECT_TYPE, + }); + }); + }); + + describe('installSource: upload', () => { + it('should call audit logger', async () => { + await createInstallation({ + savedObjectsClient: soClient, + packageInfo, + installSource: 'upload', + spaceId: DEFAULT_SPACE_ID, + }); + + expect(mockedAuditLoggingService.writeCustomSoAuditLog).toHaveBeenCalledWith({ + action: 'create', + id: 'test-package', + savedObjectType: PACKAGES_SAVED_OBJECT_TYPE, + }); + }); + }); +}); describe('install', () => { beforeEach(() => { diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install.ts b/x-pack/plugins/fleet/server/services/epm/packages/install.ts index 0b5b86407100e..a9b9afcf71ed6 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install.ts @@ -68,6 +68,8 @@ import { prepareToInstallPipelines } from '../elasticsearch/ingest_pipeline'; import { prepareToInstallTemplates } from '../elasticsearch/template/install'; +import { auditLoggingService } from '../../audit_logging'; + import { formatVerificationResultForSO } from './package_verification'; import { getInstallation, getInstallationObject } from '.'; @@ -670,6 +672,12 @@ export const updateVersion = async ( pkgName: string, pkgVersion: string ) => { + auditLoggingService.writeCustomSoAuditLog({ + action: 'update', + id: pkgName, + savedObjectType: PACKAGES_SAVED_OBJECT_TYPE, + }); + return savedObjectsClient.update(PACKAGES_SAVED_OBJECT_TYPE, pkgName, { version: pkgVersion, }); @@ -684,6 +692,12 @@ export const updateInstallStatus = async ({ pkgName: string; status: EpmPackageInstallStatus; }) => { + auditLoggingService.writeCustomSoAuditLog({ + action: 'update', + id: pkgName, + savedObjectType: PACKAGES_SAVED_OBJECT_TYPE, + }); + return savedObjectsClient.update(PACKAGES_SAVED_OBJECT_TYPE, pkgName, { install_status: status, }); @@ -713,6 +727,12 @@ export async function restartInstallation(options: { }; } + auditLoggingService.writeCustomSoAuditLog({ + action: 'update', + id: pkgName, + savedObjectType: PACKAGES_SAVED_OBJECT_TYPE, + }); + await savedObjectsClient.update(PACKAGES_SAVED_OBJECT_TYPE, pkgName, savedObjectUpdate); } @@ -757,6 +777,12 @@ export async function createInstallation(options: { savedObject = { ...savedObject, ...formatVerificationResultForSO(verificationResult) }; } + auditLoggingService.writeCustomSoAuditLog({ + action: 'create', + id: pkgName, + savedObjectType: PACKAGES_SAVED_OBJECT_TYPE, + }); + const created = await savedObjectsClient.create( PACKAGES_SAVED_OBJECT_TYPE, savedObject, @@ -771,6 +797,12 @@ export const saveKibanaAssetsRefs = async ( pkgName: string, kibanaAssets: Record ) => { + auditLoggingService.writeCustomSoAuditLog({ + action: 'update', + id: pkgName, + savedObjectType: PACKAGES_SAVED_OBJECT_TYPE, + }); + const assetRefs = Object.values(kibanaAssets).flat().map(toAssetReference); // Because Kibana assets are installed in parallel with ES assets with refresh: false, we almost always run into an // issue that causes a conflict error due to this issue: https://github.com/elastic/kibana/issues/126240. This is safe @@ -829,6 +861,12 @@ export const updateEsAssetReferences = async ( ({ type, id }) => `${type}-${id}` ); + auditLoggingService.writeCustomSoAuditLog({ + action: 'update', + id: pkgName, + savedObjectType: PACKAGES_SAVED_OBJECT_TYPE, + }); + const { attributes: { installed_es: updatedAssets }, } = @@ -864,7 +902,13 @@ export const optimisticallyAddEsAssetReferences = async ( assetsToAdd: EsAssetReference[] ): Promise => { const addEsAssets = async () => { + // TODO: Should this be replaced by a `get()` call from epm/get.ts? const so = await savedObjectsClient.get(PACKAGES_SAVED_OBJECT_TYPE, pkgName); + auditLoggingService.writeCustomSoAuditLog({ + action: 'get', + id: pkgName, + savedObjectType: PACKAGES_SAVED_OBJECT_TYPE, + }); const installedEs = so.attributes.installed_es ?? []; @@ -873,6 +917,12 @@ export const optimisticallyAddEsAssetReferences = async ( ({ type, id }) => `${type}-${id}` ); + auditLoggingService.writeCustomSoAuditLog({ + action: 'update', + id: pkgName, + savedObjectType: PACKAGES_SAVED_OBJECT_TYPE, + }); + const { attributes: { installed_es: updatedAssets }, } = await savedObjectsClient.update( diff --git a/x-pack/plugins/fleet/server/services/epm/packages/remove.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/remove.test.ts index bfc03c1171004..ff03dbf5fd422 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/remove.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/remove.test.ts @@ -5,7 +5,10 @@ * 2.0. */ +import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../../common'; + import { packagePolicyService } from '../..'; +import { auditLoggingService } from '../../audit_logging'; import { removeInstallation } from './remove'; @@ -23,7 +26,9 @@ jest.mock('../..', () => { }, }; }); +jest.mock('../../audit_logging'); +const mockedAuditLoggingService = auditLoggingService as jest.Mocked; const mockPackagePolicyService = packagePolicyService as jest.Mocked; describe('removeInstallation', () => { @@ -66,4 +71,20 @@ describe('removeInstallation', () => { `unable to remove package with existing package policy(s) in use by agent(s)` ); }); + + it('should call audit logger', async () => { + await removeInstallation({ + savedObjectsClient: soClientMock, + pkgName: 'system', + pkgVersion: '1.0.0', + esClient: esClientMock, + force: true, + }); + + expect(mockedAuditLoggingService.writeCustomSoAuditLog).toHaveBeenCalledWith({ + action: 'delete', + id: 'system', + savedObjectType: PACKAGES_SAVED_OBJECT_TYPE, + }); + }); }); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/remove.ts b/x-pack/plugins/fleet/server/services/epm/packages/remove.ts index 4a4183965913a..0485662b584c7 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/remove.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/remove.ts @@ -41,6 +41,8 @@ import { deletePackageCache } from '../archive'; import { deleteIlms } from '../elasticsearch/datastream_ilm/remove'; import { removeArchiveEntries } from '../archive/storage'; +import { auditLoggingService } from '../../audit_logging'; + import { getInstallation, kibanaSavedObjectTypes } from '.'; export async function removeInstallation(options: { @@ -85,6 +87,11 @@ export async function removeInstallation(options: { // Delete the manager saved object with references to the asset objects // could also update with [] or some other state + auditLoggingService.writeCustomSoAuditLog({ + action: 'delete', + id: pkgName, + savedObjectType: PACKAGES_SAVED_OBJECT_TYPE, + }); await savedObjectsClient.delete(PACKAGES_SAVED_OBJECT_TYPE, pkgName); // delete the index patterns if no packages are installed @@ -118,6 +125,14 @@ async function deleteKibanaAssets( { namespace } ); + for (const { saved_object: savedObject } of resolvedObjects) { + auditLoggingService.writeCustomSoAuditLog({ + action: 'get', + id: savedObject.id, + savedObjectType: savedObject.type, + }); + } + const foundObjects = resolvedObjects.filter( ({ saved_object: savedObject }) => savedObject?.error?.statusCode !== 404 ); @@ -126,6 +141,14 @@ async function deleteKibanaAssets( // we filter these out before calling delete const assetsToDelete = foundObjects.map(({ saved_object: { id, type } }) => ({ id, type })); + for (const asset of assetsToDelete) { + auditLoggingService.writeCustomSoAuditLog({ + action: 'delete', + id: asset.id, + savedObjectType: asset.type, + }); + } + return savedObjectsClient.bulkDelete(assetsToDelete, { namespace }); } diff --git a/x-pack/plugins/fleet/server/services/epm/packages/update.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/update.test.ts new file mode 100644 index 0000000000000..123aed4d74414 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/update.test.ts @@ -0,0 +1,79 @@ +/* + * 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 { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks'; + +import { createAppContextStartContractMock } from '../../../mocks'; + +import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../../common'; +import { appContextService } from '../../app_context'; + +import { auditLoggingService } from '../../audit_logging'; + +import { updatePackage } from './update'; +import { getPackageInfo, getInstallationObject } from './get'; + +jest.mock('./get'); +jest.mock('../../audit_logging'); + +const mockedAuditLoggingService = auditLoggingService as jest.Mocked; +const mockGetPackageInfo = getPackageInfo as jest.MockedFunction; +const mockGetInstallationObject = getInstallationObject as jest.MockedFunction< + typeof getInstallationObject +>; + +describe('updatePackage', () => { + let mockContract: ReturnType; + + beforeEach(() => { + mockContract = createAppContextStartContractMock(); + appContextService.start(mockContract); + }); + + afterEach(() => { + appContextService.stop(); + }); + + it('should call audit logger', async () => { + const savedObjectsClient = savedObjectsClientMock.create(); + + mockGetPackageInfo.mockResolvedValueOnce({ + name: 'test-package', + title: 'Test package', + description: 'Test package', + format_version: '1.0.0', + version: '1.0.0', + latestVersion: '1.0.0', + owner: { + github: 'elastic', + }, + assets: { + elasticsearch: {}, + kibana: {}, + }, + } as any); + + mockGetInstallationObject.mockResolvedValueOnce({ + id: 'test-package', + attributes: {} as any, + references: [], + type: PACKAGES_SAVED_OBJECT_TYPE, + }); + + await updatePackage({ + savedObjectsClient, + pkgName: 'test-package', + keepPoliciesUpToDate: true, + }); + + expect(mockedAuditLoggingService.writeCustomSoAuditLog).toHaveBeenCalledWith({ + action: 'update', + id: 'test-package', + savedObjectType: PACKAGES_SAVED_OBJECT_TYPE, + }); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/update.ts b/x-pack/plugins/fleet/server/services/epm/packages/update.ts index aa75ab3eae1dd..3f3633708b94c 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/update.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/update.ts @@ -14,6 +14,8 @@ import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../constants'; import type { Installation, UpdatePackageRequestSchema } from '../../../types'; import { FleetError } from '../../../errors'; +import { auditLoggingService } from '../../audit_logging'; + import { getInstallationObject, getPackageInfo } from './get'; export async function updatePackage( @@ -30,6 +32,12 @@ export async function updatePackage( throw new FleetError(`package ${pkgName} is not installed`); } + auditLoggingService.writeCustomSoAuditLog({ + action: 'update', + id: installedPackage.id, + savedObjectType: PACKAGES_SAVED_OBJECT_TYPE, + }); + await savedObjectsClient.update(PACKAGES_SAVED_OBJECT_TYPE, installedPackage.id, { keep_policies_up_to_date: keepPoliciesUpToDate ?? false, }); @@ -51,6 +59,12 @@ export async function updateDatastreamExperimentalFeatures( features: Record; }> ) { + auditLoggingService.writeCustomSoAuditLog({ + action: 'update', + id: pkgName, + savedObjectType: PACKAGES_SAVED_OBJECT_TYPE, + }); + await savedObjectsClient.update( PACKAGES_SAVED_OBJECT_TYPE, pkgName, diff --git a/x-pack/plugins/fleet/server/services/index.ts b/x-pack/plugins/fleet/server/services/index.ts index c2abd872d5df9..69f5889bcd210 100644 --- a/x-pack/plugins/fleet/server/services/index.ts +++ b/x-pack/plugins/fleet/server/services/index.ts @@ -50,6 +50,7 @@ export { dataStreamService } from './data_streams'; // Plugin services export { appContextService } from './app_context'; export { licenseService } from './license'; +export { auditLoggingService } from './audit_logging'; // Artifacts services export * from './artifacts'; diff --git a/x-pack/plugins/fleet/server/services/managed_package_policies.test.ts b/x-pack/plugins/fleet/server/services/managed_package_policies.test.ts index 4110438f8f3fd..0882fff0ec34f 100644 --- a/x-pack/plugins/fleet/server/services/managed_package_policies.test.ts +++ b/x-pack/plugins/fleet/server/services/managed_package_policies.test.ts @@ -23,6 +23,7 @@ jest.mock('./app_context', () => { }, }; }); +jest.mock('./audit_logging'); describe('upgradeManagedPackagePolicies', () => { afterEach(() => { diff --git a/x-pack/plugins/fleet/server/services/output.test.ts b/x-pack/plugins/fleet/server/services/output.test.ts index 9c97cbe153284..11d6bf8936ee4 100644 --- a/x-pack/plugins/fleet/server/services/output.test.ts +++ b/x-pack/plugins/fleet/server/services/output.test.ts @@ -7,16 +7,27 @@ import { savedObjectsClientMock, elasticsearchServiceMock } from '@kbn/core/server/mocks'; +import { securityMock } from '@kbn/security-plugin/server/mocks'; + import type { OutputSOAttributes } from '../types'; +import { OUTPUT_SAVED_OBJECT_TYPE } from '../constants'; + import { outputService, outputIdToUuid } from './output'; import { appContextService } from './app_context'; import { agentPolicyService } from './agent_policy'; +import { auditLoggingService } from './audit_logging'; jest.mock('./app_context'); jest.mock('./agent_policy'); +jest.mock('./audit_logging'); +const mockedAuditLoggingService = auditLoggingService as jest.Mocked; const mockedAppContextService = appContextService as jest.Mocked; +mockedAppContextService.getSecuritySetup.mockImplementation(() => ({ + ...securityMock.createSetup(), +})); + const mockedAgentPolicyService = agentPolicyService as jest.Mocked; const CLOUD_ID = @@ -177,6 +188,7 @@ describe('Output Service', () => { mockedAgentPolicyService.removeOutputFromAll.mockReset(); mockedAppContextService.getInternalUserSOClient.mockReset(); mockedAppContextService.getEncryptedSavedObjectsSetup.mockReset(); + mockedAuditLoggingService.writeCustomSoAuditLog.mockReset(); }); describe('create', () => { it('work with a predefined id', async () => { @@ -494,6 +506,28 @@ describe('Output Service', () => { { force: true } ); }); + + it('should call audit logger', async () => { + const soClient = getMockedSoClient(); + + await outputService.create( + soClient, + esClientMock, + { + is_default: false, + is_default_monitoring: true, + name: 'Test', + type: 'elasticsearch', + }, + { id: 'output-test' } + ); + + expect(mockedAuditLoggingService.writeCustomSoAuditLog).toHaveBeenCalledWith({ + action: 'create', + id: outputIdToUuid('output-test'), + savedObjectType: OUTPUT_SAVED_OBJECT_TYPE, + }); + }); }); describe('update', () => { @@ -823,6 +857,20 @@ describe('Output Service', () => { 'Logstash output cannot be used with Fleet Server integration in fleet server policy. Please create a new ElasticSearch output.' ); }); + + it('should call audit logger', async () => { + const soClient = getMockedSoClient({ defaultOutputId: 'existing-es-output' }); + + await outputService.update(soClient, esClientMock, 'existing-es-output', { + hosts: ['new-host:443'], + }); + + expect(mockedAuditLoggingService.writeCustomSoAuditLog).toHaveBeenCalledWith({ + action: 'update', + id: outputIdToUuid('existing-es-output'), + savedObjectType: OUTPUT_SAVED_OBJECT_TYPE, + }); + }); }); describe('delete', () => { @@ -853,6 +901,17 @@ describe('Output Service', () => { expect(mockedAgentPolicyService.removeOutputFromAll).toBeCalled(); expect(soClient.delete).toBeCalled(); }); + + it('should call audit logger', async () => { + const soClient = getMockedSoClient(); + await outputService.delete(soClient, 'existing-es-output'); + + expect(mockedAuditLoggingService.writeCustomSoAuditLog).toHaveBeenCalledWith({ + action: 'delete', + id: outputIdToUuid('existing-es-output'), + savedObjectType: OUTPUT_SAVED_OBJECT_TYPE, + }); + }); }); describe('get', () => { @@ -864,6 +923,17 @@ describe('Output Service', () => { expect(output.id).toEqual('output-test'); }); + + it('should call audit logger', async () => { + const soClient = getMockedSoClient(); + await outputService.get(soClient, 'existing-es-output'); + + expect(mockedAuditLoggingService.writeCustomSoAuditLog).toHaveBeenCalledWith({ + action: 'get', + id: outputIdToUuid('existing-es-output'), + savedObjectType: OUTPUT_SAVED_OBJECT_TYPE, + }); + }); }); describe('getDefaultDataOutputId', () => { diff --git a/x-pack/plugins/fleet/server/services/output.ts b/x-pack/plugins/fleet/server/services/output.ts index a8068681ac941..eebf714716d19 100644 --- a/x-pack/plugins/fleet/server/services/output.ts +++ b/x-pack/plugins/fleet/server/services/output.ts @@ -4,16 +4,17 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { v5 as uuidv5 } from 'uuid'; +import { omit } from 'lodash'; +import { safeLoad } from 'js-yaml'; +import { SavedObjectsUtils } from '@kbn/core/server'; import type { KibanaRequest, SavedObject, SavedObjectsClientContract, ElasticsearchClient, } from '@kbn/core/server'; -import { v5 as uuidv5 } from 'uuid'; -import { omit } from 'lodash'; -import { safeLoad } from 'js-yaml'; import type { NewOutput, Output, OutputSOAttributes, AgentPolicy } from '../types'; import { @@ -33,6 +34,7 @@ import { import { agentPolicyService } from './agent_policy'; import { appContextService } from './app_context'; import { escapeSearchQueryPhrase } from './saved_object'; +import { auditLoggingService } from './audit_logging'; type Nullable = { [P in keyof T]: T[P] | null }; @@ -198,19 +200,39 @@ class OutputService { } private async _getDefaultDataOutputsSO(soClient: SavedObjectsClientContract) { - return await this.encryptedSoClient.find({ + const outputs = await this.encryptedSoClient.find({ type: OUTPUT_SAVED_OBJECT_TYPE, searchFields: ['is_default'], search: 'true', }); + + for (const output of outputs.saved_objects) { + auditLoggingService.writeCustomSoAuditLog({ + action: 'get', + id: output.id, + savedObjectType: OUTPUT_SAVED_OBJECT_TYPE, + }); + } + + return outputs; } private async _getDefaultMonitoringOutputsSO(soClient: SavedObjectsClientContract) { - return await this.encryptedSoClient.find({ + const outputs = await this.encryptedSoClient.find({ type: OUTPUT_SAVED_OBJECT_TYPE, searchFields: ['is_default_monitoring'], search: 'true', }); + + for (const output of outputs.saved_objects) { + auditLoggingService.writeCustomSoAuditLog({ + action: 'get', + id: output.id, + savedObjectType: OUTPUT_SAVED_OBJECT_TYPE, + }); + } + + return outputs; } public async ensureDefaultOutput( @@ -360,9 +382,17 @@ class OutputService { } } + const id = options?.id ? outputIdToUuid(options.id) : SavedObjectsUtils.generateId(); + + auditLoggingService.writeCustomSoAuditLog({ + action: 'create', + id, + savedObjectType: OUTPUT_SAVED_OBJECT_TYPE, + }); + const newSo = await this.encryptedSoClient.create(SAVED_OBJECT_TYPE, data, { overwrite: options?.overwrite || options?.fromPreconfiguration, - id: options?.id ? outputIdToUuid(options.id) : undefined, + id, }); return outputSavedObjectToOutput(newSo); @@ -400,6 +430,14 @@ class OutputService { sortOrder: 'desc', }); + for (const output of outputs.saved_objects) { + auditLoggingService.writeCustomSoAuditLog({ + action: 'get', + id: output.id, + savedObjectType: OUTPUT_SAVED_OBJECT_TYPE, + }); + } + return { items: outputs.saved_objects.map(outputSavedObjectToOutput), total: outputs.total, @@ -417,6 +455,14 @@ class OutputService { search: escapeSearchQueryPhrase(proxyId), }); + for (const output of outputs.saved_objects) { + auditLoggingService.writeCustomSoAuditLog({ + action: 'get', + id: output.id, + savedObjectType: OUTPUT_SAVED_OBJECT_TYPE, + }); + } + return { items: outputs.saved_objects.map(outputSavedObjectToOutput), total: outputs.total, @@ -431,6 +477,12 @@ class OutputService { outputIdToUuid(id) ); + auditLoggingService.writeCustomSoAuditLog({ + action: 'get', + id: outputSO.id, + savedObjectType: OUTPUT_SAVED_OBJECT_TYPE, + }); + if (outputSO.error) { throw new Error(outputSO.error.message); } @@ -467,6 +519,12 @@ class OutputService { id ); + auditLoggingService.writeCustomSoAuditLog({ + action: 'delete', + id: outputIdToUuid(id), + savedObjectType: OUTPUT_SAVED_OBJECT_TYPE, + }); + return this.encryptedSoClient.delete(SAVED_OBJECT_TYPE, outputIdToUuid(id)); } @@ -555,6 +613,12 @@ class OutputService { } } + auditLoggingService.writeCustomSoAuditLog({ + action: 'update', + id: outputIdToUuid(id), + savedObjectType: OUTPUT_SAVED_OBJECT_TYPE, + }); + const outputSO = await this.encryptedSoClient.update>( SAVED_OBJECT_TYPE, outputIdToUuid(id), diff --git a/x-pack/plugins/fleet/server/services/package_policies/experimental_datastream_features.test.ts b/x-pack/plugins/fleet/server/services/package_policies/experimental_datastream_features.test.ts index af62a34d73bc2..4fb8073ccbf00 100644 --- a/x-pack/plugins/fleet/server/services/package_policies/experimental_datastream_features.test.ts +++ b/x-pack/plugins/fleet/server/services/package_policies/experimental_datastream_features.test.ts @@ -7,8 +7,10 @@ import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks'; +import { securityMock } from '@kbn/security-plugin/server/mocks'; import type { NewPackagePolicy, PackagePolicy } from '../../types'; +import { appContextService } from '../app_context'; import { updateCurrentWriteIndices } from '../epm/elasticsearch/template/template'; import { getInstallation } from '../epm/packages'; @@ -33,6 +35,11 @@ jest.mock('../epm/packages', () => { }); jest.mock('../app_context'); +const mockedAppContextService = appContextService as jest.Mocked; +mockedAppContextService.getSecuritySetup.mockImplementation(() => ({ + ...securityMock.createSetup(), +})); + jest.mock('../epm/elasticsearch/template/template'); const mockGetInstallation = getInstallation as jest.Mock; diff --git a/x-pack/plugins/fleet/server/services/package_policy.test.ts b/x-pack/plugins/fleet/server/services/package_policy.test.ts index 343b6129d0a1a..314dc6c5f1bd1 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.test.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.test.ts @@ -14,7 +14,6 @@ import { import { produce } from 'immer'; import type { KibanaRequest, - SavedObjectsClient, SavedObjectsClientContract, SavedObjectsUpdateResponse, } from '@kbn/core/server'; @@ -23,7 +22,6 @@ import { SavedObjectsErrorHelpers } from '@kbn/core/server'; import type { PackageInfo, PackagePolicySOAttributes, - AgentPolicySOAttributes, PostPackagePolicyPostDeleteCallback, RegistryDataStream, PackagePolicyInputStream, @@ -49,6 +47,8 @@ import { packageToPackagePolicy } from '../../common/services'; import { FleetError, PackagePolicyIneligibleForUpgradeError } from '../errors'; +import { PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '../constants'; + import { preconfigurePackageInputs, updatePackageInputs, @@ -61,6 +61,8 @@ import { appContextService } from './app_context'; import { getPackageInfo } from './epm/packages'; import { sendTelemetryEvents } from './upgrade_sender'; +import { auditLoggingService } from './audit_logging'; +import { agentPolicyService } from './agent_policy'; const mockedSendTelemetryEvents = sendTelemetryEvents as jest.MockedFunction< typeof sendTelemetryEvents @@ -176,26 +178,8 @@ jest.mock('../../common/services/package_to_package_policy', () => ({ jest.mock('./epm/registry'); -jest.mock('./agent_policy', () => { - return { - agentPolicyService: { - get: async (soClient: SavedObjectsClient, id: string) => { - const agentPolicySO = await soClient.get( - 'ingest-agent-policies', - id - ); - if (!agentPolicySO) { - return null; - } - const agentPolicy = { id: agentPolicySO.id, ...agentPolicySO.attributes }; - agentPolicy.package_policies = []; - return agentPolicy; - }, - bumpRevision: () => {}, - getDefaultAgentPolicyId: () => Promise.resolve('1'), - }, - }; -}); +jest.mock('./agent_policy'); +const mockAgentPolicyService = agentPolicyService as jest.Mocked; jest.mock('./epm/packages/cleanup', () => { return { @@ -209,9 +193,238 @@ jest.mock('./upgrade_sender', () => { }; }); +jest.mock('./audit_logging'); +const mockedAuditLoggingService = auditLoggingService as jest.Mocked; + type CombinedExternalCallback = PutPackagePolicyUpdateCallback | PostPackagePolicyCreateCallback; +const mockAgentPolicyGet = () => { + mockAgentPolicyService.get.mockImplementation( + (_soClient: SavedObjectsClientContract, id: string, _force = false, _errorMessage?: string) => { + return Promise.resolve({ + id, + name: 'Test Agent Policy', + namespace: 'test', + status: 'active', + is_managed: false, + updated_at: new Date().toISOString(), + updated_by: 'test', + revision: 1, + }); + } + ); +}; + describe('Package policy service', () => { + beforeEach(() => { + appContextService.start(createAppContextStartContractMock()); + }); + + afterEach(() => { + appContextService.stop(); + + // `jest.resetAllMocks` breaks a ton of tests in this file 🤷‍♂️ + mockAgentPolicyService.get.mockReset(); + mockedAuditLoggingService.writeCustomSoAuditLog.mockReset(); + }); + + describe('create', () => { + it('should call audit logger', async () => { + 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(); + + await packagePolicyService.create( + soClient, + esClient, + { + name: 'Test Package Policy', + namespace: 'test', + enabled: true, + policy_id: 'test', + inputs: [], + }, + // Skipping unique name verification just means we have to less mocking/setup + { id: 'test-package-policy', skipUniqueNameVerification: true } + ); + + expect(mockedAuditLoggingService.writeCustomSoAuditLog).toBeCalledWith({ + action: 'create', + id: 'test-package-policy', + savedObjectType: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + }); + }); + }); + + describe('bulkCreate', () => { + it('should call audit logger', async () => { + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + const soClient = savedObjectsClientMock.create(); + + soClient.bulkCreate.mockResolvedValueOnce({ + saved_objects: [ + { + id: 'test-package-policy-1', + attributes: {}, + references: [], + type: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + }, + { + id: 'test-package-policy-2', + attributes: {}, + references: [], + type: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + }, + ], + }); + + mockAgentPolicyGet(); + + await packagePolicyService.bulkCreate(soClient, esClient, [ + { + id: 'test-package-policy-1', + name: 'Test Package Policy 1', + namespace: 'test', + enabled: true, + policy_id: 'test_agent_policy', + inputs: [], + }, + { + id: 'test-package-policy-2', + name: 'Test Package Policy 2', + namespace: 'test', + enabled: true, + policy_id: 'test_agent_policy', + inputs: [], + }, + ]); + + expect(mockedAuditLoggingService.writeCustomSoAuditLog).toHaveBeenNthCalledWith(1, { + action: 'create', + id: 'test-package-policy-1', + savedObjectType: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + }); + + expect(mockedAuditLoggingService.writeCustomSoAuditLog).toHaveBeenNthCalledWith(2, { + action: 'create', + id: 'test-package-policy-2', + savedObjectType: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + }); + }); + }); + + describe('get', () => { + it('should call audit logger', async () => { + const soClient = savedObjectsClientMock.create(); + soClient.get.mockResolvedValueOnce({ + id: 'test-package-policy', + attributes: {}, + references: [], + type: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + }); + + await packagePolicyService.get(soClient, 'test-package-policy'); + + expect(mockedAuditLoggingService.writeCustomSoAuditLog).toBeCalledWith({ + action: 'get', + id: 'test-package-policy', + savedObjectType: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + }); + }); + }); + + describe('getByIDs', () => { + it('should call audit logger', async () => { + const soClient = savedObjectsClientMock.create(); + soClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [ + { + id: 'test-package-policy-1', + attributes: {}, + references: [], + type: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + }, + { + id: 'test-package-policy-2', + attributes: {}, + references: [], + type: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + }, + ], + }); + + await packagePolicyService.getByIDs(soClient, [ + 'test-package-policy-1', + 'test-package-policy-2', + ]); + + expect(mockedAuditLoggingService.writeCustomSoAuditLog).toHaveBeenNthCalledWith(1, { + action: 'get', + id: 'test-package-policy-1', + savedObjectType: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + }); + + expect(mockedAuditLoggingService.writeCustomSoAuditLog).toHaveBeenNthCalledWith(2, { + action: 'get', + id: 'test-package-policy-2', + savedObjectType: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + }); + }); + }); + + describe('list', () => { + it('should call audit logger', async () => { + const soClient = savedObjectsClientMock.create(); + soClient.find.mockResolvedValueOnce({ + total: 1, + page: 1, + per_page: 10, + saved_objects: [ + { + id: 'test-package-policy-1', + attributes: {}, + references: [], + type: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + score: 0, + }, + { + id: 'test-package-policy-2', + attributes: {}, + references: [], + type: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + score: 0, + }, + ], + }); + + await packagePolicyService.list(soClient, { + page: 1, + perPage: 1, + kuery: '', + }); + + expect(mockedAuditLoggingService.writeCustomSoAuditLog).toHaveBeenNthCalledWith(1, { + action: 'find', + id: 'test-package-policy-1', + savedObjectType: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + }); + + expect(mockedAuditLoggingService.writeCustomSoAuditLog).toHaveBeenNthCalledWith(2, { + action: 'find', + id: 'test-package-policy-2', + savedObjectType: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + }); + }); + }); + describe('_compilePackagePolicyInputs', () => { it('should work with config variables from the stream', async () => { const inputs = await _compilePackagePolicyInputs( @@ -640,12 +853,6 @@ describe('Package policy service', () => { }); describe('update', () => { - beforeEach(() => { - appContextService.start(createAppContextStartContractMock()); - }); - afterEach(() => { - appContextService.stop(); - }); it('should fail to update on version conflict', async () => { const savedObjectsClient = savedObjectsClientMock.create(); savedObjectsClient.get.mockResolvedValue({ @@ -1377,16 +1584,49 @@ describe('Package policy service', () => { expect(result.name).toEqual('test'); }); + + it('should call audit logger', async () => { + const soClient = savedObjectsClientMock.create(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + + const mockPackagePolicy = createPackagePolicyMock(); + + const attributes = { + ...mockPackagePolicy, + inputs: [], + }; + + soClient.get.mockResolvedValue({ + id: 'test-package-policy', + type: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + references: [], + attributes, + }); + + soClient.update.mockResolvedValue({ + id: 'test-package-policy', + type: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + references: [], + attributes, + }); + + await packagePolicyService.update(soClient, esClient, 'test-package-policy', { + ...mockPackagePolicy, + inputs: [], + }); + + expect(mockedAuditLoggingService.writeCustomSoAuditLog).toHaveBeenCalledWith({ + action: 'update', + id: 'test-package-policy', + savedObjectType: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + }); + }); }); describe('bulkUpdate', () => { beforeEach(() => { - appContextService.start(createAppContextStartContractMock()); mockedSendTelemetryEvents.mockReset(); }); - afterEach(() => { - appContextService.stop(); - }); it('should throw if the user try to update input vars that are frozen', async () => { const savedObjectsClient = savedObjectsClientMock.create(); @@ -2113,10 +2353,86 @@ describe('Package policy service', () => { expect(mockedSendTelemetryEvents).not.toBeCalled(); }); + + it('should call audit logger', async () => { + const soClient = savedObjectsClientMock.create(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + + const mockPackagePolicies = [ + { + id: 'test-package-policy-1', + type: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + attributes: {}, + references: [], + }, + { + id: 'test-package-policy-2', + type: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + attributes: {}, + references: [], + }, + ]; + + soClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [...mockPackagePolicies], + }); + + soClient.bulkUpdate.mockResolvedValueOnce({ + saved_objects: [...mockPackagePolicies], + }); + + await packagePolicyService.bulkUpdate(soClient, esClient, [ + { + id: 'test-package-policy-1', + name: 'Test Package Policy 1', + namespace: 'test', + enabled: true, + policy_id: 'test-agent-policy', + inputs: [], + }, + { + id: 'test-package-policy-2', + name: 'Test Package Policy 2', + namespace: 'test', + enabled: true, + policy_id: 'test-agent-policy', + inputs: [], + }, + ]); + }); }); describe('delete', () => { + // TODO: Add tests it('should allow to delete a package policy', async () => {}); + + it('should call audit logger', async () => { + const soClient = savedObjectsClientMock.create(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + + const mockPackagePolicy = { + id: 'test-package-policy', + type: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + attributes: {}, + references: [], + }; + + soClient.get.mockResolvedValueOnce({ + ...mockPackagePolicy, + }); + + soClient.delete.mockResolvedValueOnce({ + ...mockPackagePolicy, + }); + + await packagePolicyService.delete(soClient, esClient, ['test-package-policy']); + + expect(mockedAuditLoggingService.writeCustomSoAuditLog).toHaveBeenCalledWith({ + action: 'delete', + id: 'test-package-policy', + savedObjectType: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + }); + }); }); describe('runPostDeleteExternalCallbacks', () => { @@ -2126,7 +2442,6 @@ describe('Package policy service', () => { let deletedPackagePolicies: PostDeletePackagePoliciesResponse; beforeEach(() => { - appContextService.start(createAppContextStartContractMock()); callingOrder = []; deletedPackagePolicies = [ { id: 'a', success: true }, @@ -2142,10 +2457,6 @@ describe('Package policy service', () => { appContextService.addExternalCallback('packagePolicyPostDelete', callbackTwo); }); - afterEach(() => { - appContextService.stop(); - }); - it('should execute external callbacks', async () => { const soClient = savedObjectsClientMock.create(); const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; @@ -2229,7 +2540,6 @@ describe('Package policy service', () => { let packagePolicies: DeletePackagePoliciesResponse; beforeEach(() => { - appContextService.start(createAppContextStartContractMock()); callingOrder = []; packagePolicies = [{ id: 'a' }, { id: 'a' }] as DeletePackagePoliciesResponse; callbackOne = jest.fn(async (deletedPolicies, soClient, esClient) => { @@ -2242,10 +2552,6 @@ describe('Package policy service', () => { appContextService.addExternalCallback('packagePolicyDelete', callbackTwo); }); - afterEach(() => { - appContextService.stop(); - }); - it('should execute external callbacks', async () => { const soClient = savedObjectsClientMock.create(); const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; @@ -2361,11 +2667,9 @@ describe('Package policy service', () => { beforeEach(() => { context = xpackMocks.createRequestHandlerContext(); request = httpServerMock.createKibanaRequest(); - appContextService.start(createAppContextStartContractMock()); }); afterEach(() => { - appContextService.stop(); jest.clearAllMocks(); callbackCallingOrder.length = 0; }); @@ -2550,11 +2854,9 @@ describe('Package policy service', () => { beforeEach(() => { context = xpackMocks.createRequestHandlerContext(); request = httpServerMock.createKibanaRequest(); - appContextService.start(createAppContextStartContractMock()); }); afterEach(() => { - appContextService.stop(); jest.clearAllMocks(); callbackCallingOrder.length = 0; }); @@ -4521,10 +4823,6 @@ describe('getUpgradeDryRunDiff', () => { let savedObjectsClient: jest.Mocked; beforeEach(() => { savedObjectsClient = savedObjectsClientMock.create(); - appContextService.start(createAppContextStartContractMock()); - }); - afterEach(() => { - appContextService.stop(); }); it('should return no errors if there is no conflict to upgrade', async () => { const res = await packagePolicyService.getUpgradeDryRunDiff( @@ -4650,10 +4948,6 @@ describe('_applyIndexPrivileges()', () => { }; } - beforeAll(async () => { - appContextService.start(createAppContextStartContractMock()); - }); - it('should do nothing if packageStream has no privileges', () => { const packageStream = createPackageStream(); const inputStream = createInputStream(); diff --git a/x-pack/plugins/fleet/server/services/package_policy.ts b/x-pack/plugins/fleet/server/services/package_policy.ts index fe044f012dd11..759a6bc3d05c3 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.ts @@ -17,6 +17,7 @@ import type { Logger, RequestHandlerContext, } from '@kbn/core/server'; +import { SavedObjectsUtils } from '@kbn/core/server'; import { v4 as uuidv4 } from 'uuid'; import { safeLoad } from 'js-yaml'; @@ -104,6 +105,7 @@ import { handleExperimentalDatastreamFeatureOptIn } from './package_policies'; import { updateDatastreamExperimentalFeatures } from './epm/packages/update'; import type { PackagePolicyClient, PackagePolicyService } from './package_policy_service'; import { installAssetsForInputPackagePolicy } from './epm/packages/install'; +import { auditLoggingService } from './audit_logging'; export type InputsOverride = Partial & { vars?: Array; @@ -125,7 +127,7 @@ class PackagePolicyClientImpl implements PackagePolicyClient { soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, packagePolicy: NewPackagePolicy, - options?: { + options: { spaceId?: string; id?: string; user?: AuthenticatedUser; @@ -135,10 +137,21 @@ class PackagePolicyClientImpl implements PackagePolicyClient { skipUniqueNameVerification?: boolean; overwrite?: boolean; packageInfo?: PackageInfo; - }, + } = {}, context?: RequestHandlerContext, request?: KibanaRequest ): Promise { + // Ensure an ID is provided, so we can include it in the audit logs below + if (!options.id) { + options.id = SavedObjectsUtils.generateId(); + } + + auditLoggingService.writeCustomSoAuditLog({ + action: 'create', + id: options.id, + savedObjectType: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + }); + const logger = appContextService.getLogger(); const enrichedPackagePolicy = await packagePolicyService.runExternalCallbacks( @@ -260,6 +273,7 @@ class PackagePolicyClientImpl implements PackagePolicyClient { } const createdPackagePolicy = { id: newSo.id, version: newSo.version, ...newSo.attributes }; + return packagePolicyService.runExternalCallbacks( 'packagePolicyPostCreate', createdPackagePolicy, @@ -278,6 +292,18 @@ class PackagePolicyClientImpl implements PackagePolicyClient { force?: true; } ): Promise { + for (const packagePolicy of packagePolicies) { + if (!packagePolicy.id) { + packagePolicy.id = SavedObjectsUtils.generateId(); + } + + auditLoggingService.writeCustomSoAuditLog({ + action: 'create', + id: packagePolicy.id, + savedObjectType: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + }); + } + const agentPolicyIds = new Set(packagePolicies.map((pkgPolicy) => pkgPolicy.policy_id)); for (const agentPolicyId of agentPolicyIds) { @@ -390,6 +416,12 @@ class PackagePolicyClientImpl implements PackagePolicyClient { response.package.experimental_data_stream_features = experimentalFeatures; } + auditLoggingService.writeCustomSoAuditLog({ + action: 'get', + id, + savedObjectType: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + }); + return response; } @@ -406,11 +438,21 @@ class PackagePolicyClientImpl implements PackagePolicyClient { return []; } - return packagePolicySO.saved_objects.map((so) => ({ + const packagePolicies = packagePolicySO.saved_objects.map((so) => ({ id: so.id, version: so.version, ...so.attributes, })); + + for (const packagePolicy of packagePolicies) { + auditLoggingService.writeCustomSoAuditLog({ + action: 'find', + id: packagePolicy.id, + savedObjectType: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + }); + } + + return packagePolicies; } public async getByIDs( @@ -428,7 +470,7 @@ class PackagePolicyClientImpl implements PackagePolicyClient { return null; } - return packagePolicySO.saved_objects + const packagePolicies = packagePolicySO.saved_objects .map((so): PackagePolicy | null => { if (so.error) { if (options.ignoreMissing && so.error.statusCode === 404) { @@ -447,6 +489,16 @@ class PackagePolicyClientImpl implements PackagePolicyClient { }; }) .filter((packagePolicy): packagePolicy is PackagePolicy => packagePolicy !== null); + + for (const packagePolicy of packagePolicies) { + auditLoggingService.writeCustomSoAuditLog({ + action: 'get', + id: packagePolicy.id, + savedObjectType: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + }); + } + + return packagePolicies; } public async list( @@ -464,6 +516,14 @@ class PackagePolicyClientImpl implements PackagePolicyClient { filter: kuery ? normalizeKuery(SAVED_OBJECT_TYPE, kuery) : undefined, }); + for (const packagePolicy of packagePolicies?.saved_objects ?? []) { + auditLoggingService.writeCustomSoAuditLog({ + action: 'find', + id: packagePolicy.id, + savedObjectType: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + }); + } + return { items: packagePolicies?.saved_objects.map((packagePolicySO) => ({ id: packagePolicySO.id, @@ -492,6 +552,14 @@ class PackagePolicyClientImpl implements PackagePolicyClient { filter: kuery ? normalizeKuery(SAVED_OBJECT_TYPE, kuery) : undefined, }); + for (const packagePolicy of packagePolicies.saved_objects) { + auditLoggingService.writeCustomSoAuditLog({ + action: 'find', + id: packagePolicy.id, + savedObjectType: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + }); + } + return { items: packagePolicies.saved_objects.map((packagePolicySO) => packagePolicySO.id), total: packagePolicies.total, @@ -507,6 +575,12 @@ class PackagePolicyClientImpl implements PackagePolicyClient { packagePolicyUpdate: UpdatePackagePolicy, options?: { user?: AuthenticatedUser; force?: boolean; skipUniqueNameVerification?: boolean } ): Promise { + auditLoggingService.writeCustomSoAuditLog({ + action: 'update', + id, + savedObjectType: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + }); + let enrichedPackagePolicy: UpdatePackagePolicy; try { @@ -647,6 +721,13 @@ class PackagePolicyClientImpl implements PackagePolicyClient { options?: { user?: AuthenticatedUser; force?: boolean }, currentVersion?: string ): Promise { + for (const packagePolicy of packagePolicyUpdates) { + auditLoggingService.writeCustomSoAuditLog({ + action: 'update', + id: packagePolicy.id, + savedObjectType: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + }); + } const oldPackagePolicies = await this.getByIDs( soClient, packagePolicyUpdates.map((p) => p.id) @@ -770,6 +851,14 @@ class PackagePolicyClientImpl implements PackagePolicyClient { context?: RequestHandlerContext, request?: KibanaRequest ): Promise { + for (const id of ids) { + auditLoggingService.writeCustomSoAuditLog({ + action: 'delete', + id, + savedObjectType: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + }); + } + const result: PostDeletePackagePoliciesResponse = []; const logger = appContextService.getLogger(); diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts index 320720c8dbedb..62758a93faa90 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts @@ -273,6 +273,8 @@ jest.mock('./app_context', () => ({ }, })); +jest.mock('./audit_logging'); + const spyAgentPolicyServiceUpdate = jest.spyOn(agentPolicy.agentPolicyService, 'update'); const spyAgentPolicyServicBumpAllAgentPoliciesForOutput = jest.spyOn( agentPolicy.agentPolicyService, diff --git a/x-pack/plugins/fleet/server/services/preconfiguration/fleet_server_host.test.ts b/x-pack/plugins/fleet/server/services/preconfiguration/fleet_server_host.test.ts index 87717860e9be4..aec64226ca5ec 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration/fleet_server_host.test.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration/fleet_server_host.test.ts @@ -5,6 +5,7 @@ * 2.0. */ import { savedObjectsClientMock } from '@kbn/core/server/mocks'; +import { securityMock } from '@kbn/security-plugin/server/mocks'; import { appContextService } from '../app_context'; import { getDefaultFleetServerHost, createFleetServerHost } from '../fleet_server_host'; @@ -19,6 +20,10 @@ jest.mock('../fleet_server_host'); jest.mock('../app_context'); const mockedAppContextService = appContextService as jest.Mocked; +mockedAppContextService.getSecuritySetup.mockImplementation(() => ({ + ...securityMock.createSetup(), +})); + const mockedGetDefaultFleetServerHost = getDefaultFleetServerHost as jest.MockedFunction< typeof getDefaultFleetServerHost >; diff --git a/x-pack/plugins/fleet/server/services/request_store.ts b/x-pack/plugins/fleet/server/services/request_store.ts new file mode 100644 index 0000000000000..bed1bc174e764 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/request_store.ts @@ -0,0 +1,16 @@ +/* + * 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 { AsyncLocalStorage } from 'async_hooks'; + +import type { KibanaRequest } from '@kbn/core-http-server'; + +export function getRequestStore() { + const requestStore = new AsyncLocalStorage(); + + return requestStore; +} diff --git a/x-pack/plugins/fleet/server/services/settings.test.ts b/x-pack/plugins/fleet/server/services/settings.test.ts index ca0be8130b21a..33926b0ec12f7 100644 --- a/x-pack/plugins/fleet/server/services/settings.test.ts +++ b/x-pack/plugins/fleet/server/services/settings.test.ts @@ -6,13 +6,31 @@ */ import { savedObjectsClientMock } from '@kbn/core/server/mocks'; +import { securityMock } from '@kbn/security-plugin/server/mocks'; + +import Boom from '@hapi/boom'; + +import { GLOBAL_SETTINGS_ID, GLOBAL_SETTINGS_SAVED_OBJECT_TYPE } from '../../common/constants'; + +import type { Settings } from '../types'; import { appContextService } from './app_context'; -import { settingsSetup } from './settings'; +import { getSettings, saveSettings, settingsSetup } from './settings'; +import { auditLoggingService } from './audit_logging'; +import { listFleetServerHosts } from './fleet_server_host'; jest.mock('./app_context'); +jest.mock('./audit_logging'); +jest.mock('./fleet_server_host'); +const mockListFleetServerHosts = listFleetServerHosts as jest.MockedFunction< + typeof listFleetServerHosts +>; +const mockedAuditLoggingService = auditLoggingService as jest.Mocked; const mockedAppContextService = appContextService as jest.Mocked; +mockedAppContextService.getSecuritySetup.mockImplementation(() => ({ + ...securityMock.createSetup(), +})); describe('settingsSetup', () => { afterEach(() => { @@ -66,8 +84,145 @@ describe('settingsSetup', () => { type: 'so_type', }); + mockListFleetServerHosts.mockResolvedValueOnce({ + items: [ + { + id: 'fleet-server-host', + name: 'Fleet Server Host', + is_default: true, + is_preconfigured: false, + host_urls: ['http://localhost:8220'], + }, + ], + page: 1, + perPage: 10, + total: 1, + }); + await settingsSetup(soClientMock); expect(soClientMock.create).not.toBeCalled(); }); }); + +describe('getSettings', () => { + it('should call audit logger', async () => { + const soClient = savedObjectsClientMock.create(); + + soClient.find.mockResolvedValueOnce({ + saved_objects: [ + { + id: GLOBAL_SETTINGS_ID, + attributes: {}, + references: [], + type: GLOBAL_SETTINGS_SAVED_OBJECT_TYPE, + score: 0, + }, + ], + page: 1, + per_page: 10, + total: 1, + }); + + mockListFleetServerHosts.mockResolvedValueOnce({ + items: [ + { + id: 'fleet-server-host', + name: 'Fleet Server Host', + is_default: true, + is_preconfigured: false, + host_urls: ['http://localhost:8220'], + }, + ], + page: 1, + perPage: 10, + total: 1, + }); + + await getSettings(soClient); + }); +}); + +describe('saveSettings', () => { + describe('when settings object exists', () => { + it('should call audit logger', async () => { + const soClient = savedObjectsClientMock.create(); + + const newData: Partial> = { + fleet_server_hosts: ['http://localhost:8220'], + }; + + soClient.find.mockResolvedValueOnce({ + saved_objects: [ + { + id: GLOBAL_SETTINGS_ID, + attributes: {}, + references: [], + type: GLOBAL_SETTINGS_SAVED_OBJECT_TYPE, + score: 0, + }, + ], + page: 1, + per_page: 10, + total: 1, + }); + + soClient.update.mockResolvedValueOnce({ + id: GLOBAL_SETTINGS_ID, + attributes: {}, + references: [], + type: GLOBAL_SETTINGS_SAVED_OBJECT_TYPE, + }); + + mockListFleetServerHosts.mockResolvedValueOnce({ + items: [ + { + id: 'fleet-server-host', + name: 'Fleet Server Host', + is_default: true, + is_preconfigured: false, + host_urls: ['http://localhost:8220'], + }, + ], + page: 1, + perPage: 10, + total: 1, + }); + + await saveSettings(soClient, newData); + + expect(mockedAuditLoggingService.writeCustomSoAuditLog).toHaveBeenCalledWith({ + action: 'create', + id: GLOBAL_SETTINGS_ID, + savedObjectType: GLOBAL_SETTINGS_SAVED_OBJECT_TYPE, + }); + }); + + describe('when settings object does not exist', () => { + it('should call audit logger', async () => { + const soClient = savedObjectsClientMock.create(); + + const newData: Partial> = { + fleet_server_hosts: ['http://localhost:8220'], + }; + + soClient.find.mockRejectedValueOnce(Boom.notFound('not found')); + + soClient.create.mockResolvedValueOnce({ + id: GLOBAL_SETTINGS_ID, + attributes: {}, + references: [], + type: GLOBAL_SETTINGS_SAVED_OBJECT_TYPE, + }); + + await saveSettings(soClient, newData); + + expect(mockedAuditLoggingService.writeCustomSoAuditLog).toHaveBeenCalledWith({ + action: 'create', + id: GLOBAL_SETTINGS_ID, + savedObjectType: GLOBAL_SETTINGS_SAVED_OBJECT_TYPE, + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/settings.ts b/x-pack/plugins/fleet/server/services/settings.ts index 7fe6c25d24b86..5b48ee8177885 100644 --- a/x-pack/plugins/fleet/server/services/settings.ts +++ b/x-pack/plugins/fleet/server/services/settings.ts @@ -14,11 +14,17 @@ import type { SettingsSOAttributes, Settings, BaseSettings } from '../../common/ import { appContextService } from './app_context'; import { listFleetServerHosts } from './fleet_server_host'; +import { auditLoggingService } from './audit_logging'; export async function getSettings(soClient: SavedObjectsClientContract): Promise { const res = await soClient.find({ type: GLOBAL_SETTINGS_SAVED_OBJECT_TYPE, }); + auditLoggingService.writeCustomSoAuditLog({ + action: 'get', + id: GLOBAL_SETTINGS_ID, + savedObjectType: GLOBAL_SETTINGS_SAVED_OBJECT_TYPE, + }); if (res.total === 0) { throw Boom.notFound('Global settings not found'); @@ -59,6 +65,12 @@ export async function saveSettings( try { const settings = await getSettings(soClient); + auditLoggingService.writeCustomSoAuditLog({ + action: 'update', + id: settings.id, + savedObjectType: GLOBAL_SETTINGS_SAVED_OBJECT_TYPE, + }); + const res = await soClient.update( GLOBAL_SETTINGS_SAVED_OBJECT_TYPE, settings.id, @@ -72,6 +84,13 @@ export async function saveSettings( } catch (e) { if (e.isBoom && e.output.statusCode === 404) { const defaultSettings = createDefaultSettings(); + + auditLoggingService.writeCustomSoAuditLog({ + action: 'create', + id: GLOBAL_SETTINGS_ID, + savedObjectType: GLOBAL_SETTINGS_SAVED_OBJECT_TYPE, + }); + const res = await soClient.create( GLOBAL_SETTINGS_SAVED_OBJECT_TYPE, { diff --git a/x-pack/plugins/security_solution/common/endpoint/models/policy_config.ts b/x-pack/plugins/security_solution/common/endpoint/models/policy_config.ts index bf1ac9097a103..b0997a6b92787 100644 --- a/x-pack/plugins/security_solution/common/endpoint/models/policy_config.ts +++ b/x-pack/plugins/security_solution/common/endpoint/models/policy_config.ts @@ -11,8 +11,11 @@ import { ProtectionModes } from '../types'; /** * Return a new default `PolicyConfig` for platinum and above licenses */ -export const policyFactory = (): PolicyConfig => { +export const policyFactory = (license = ''): PolicyConfig => { return { + meta: { + license, + }, windows: { events: { credential_access: true, diff --git a/x-pack/plugins/security_solution/common/endpoint/models/policy_config_helpers.test.ts b/x-pack/plugins/security_solution/common/endpoint/models/policy_config_helpers.test.ts index a5c126f587b53..b35bbc190fe43 100644 --- a/x-pack/plugins/security_solution/common/endpoint/models/policy_config_helpers.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/models/policy_config_helpers.test.ts @@ -119,6 +119,7 @@ describe('Policy Config helpers', () => { // This constant makes sure that if the type `PolicyConfig` is ever modified, // the logic for disabling protections is also modified due to type check. export const eventsOnlyPolicy: PolicyConfig = { + meta: { license: '' }, windows: { events: { credential_access: true, diff --git a/x-pack/plugins/security_solution/common/endpoint/models/policy_config_helpers.ts b/x-pack/plugins/security_solution/common/endpoint/models/policy_config_helpers.ts index 27a15e0be5401..4bdca10547bc2 100644 --- a/x-pack/plugins/security_solution/common/endpoint/models/policy_config_helpers.ts +++ b/x-pack/plugins/security_solution/common/endpoint/models/policy_config_helpers.ts @@ -31,29 +31,29 @@ export const disableProtections = (policy: PolicyConfig): PolicyConfig => { }; const disableCommonProtections = (policy: PolicyConfig) => { - let policyOutput = policy; - - for (const key in policyOutput) { - if (Object.prototype.hasOwnProperty.call(policyOutput, key)) { - const os = key as keyof PolicyConfig; - - policyOutput = { - ...policyOutput, - [os]: { - ...policyOutput[os], - ...getDisabledCommonProtectionsForOS(policyOutput, os), - popup: { - ...policyOutput[os].popup, - ...getDisabledCommonPopupsForOS(policyOutput, os), - }, - }, - }; + return Object.keys(policy).reduce((acc, item) => { + const os = item as keyof PolicyConfig; + if (os === 'meta') { + return acc; } - } - return policyOutput; + return { + ...acc, + [os]: { + ...policy[os], + ...getDisabledCommonProtectionsForOS(policy, os), + popup: { + ...policy[os].popup, + ...getDisabledCommonPopupsForOS(policy, os), + }, + }, + }; + }, policy); }; -const getDisabledCommonProtectionsForOS = (policy: PolicyConfig, os: keyof PolicyConfig) => ({ +const getDisabledCommonProtectionsForOS = ( + policy: PolicyConfig, + os: keyof Omit +) => ({ behavior_protection: { ...policy[os].behavior_protection, mode: ProtectionModes.off, @@ -69,7 +69,10 @@ const getDisabledCommonProtectionsForOS = (policy: PolicyConfig, os: keyof Polic }, }); -const getDisabledCommonPopupsForOS = (policy: PolicyConfig, os: keyof PolicyConfig) => ({ +const getDisabledCommonPopupsForOS = ( + policy: PolicyConfig, + os: keyof Omit +) => ({ behavior_protection: { ...policy[os].popup.behavior_protection, enabled: false, diff --git a/x-pack/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts index 790645ad2e70f..d9d0677ed8f17 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -920,6 +920,9 @@ type KbnConfigSchemaNonOptionalProps> = Pi * Endpoint Policy configuration */ export interface PolicyConfig { + meta: { + license: string; + }; windows: { advanced?: { [key: string]: unknown; diff --git a/x-pack/plugins/security_solution/common/license/license.ts b/x-pack/plugins/security_solution/common/license/license.ts index c82fc21d80ceb..0054835d6d779 100644 --- a/x-pack/plugins/security_solution/common/license/license.ts +++ b/x-pack/plugins/security_solution/common/license/license.ts @@ -38,6 +38,12 @@ export class LicenseService { return this.observable; } + public getLicenseType() { + return this.licenseInformation && this.licenseInformation.type + ? this.licenseInformation.type + : ''; + } + public isAtLeast(level: LicenseType): boolean { return isAtLeast(this.licenseInformation, level); } diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/index.test.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/index.test.ts index f96a6c06cb8cf..f4035498f81df 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/index.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/index.test.ts @@ -269,6 +269,7 @@ describe('policy details: ', () => { }, policy: { value: { + meta: { license: '' }, windows: { events: { credential_access: true, diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/policy/license_watch.ts b/x-pack/plugins/security_solution/server/endpoint/lib/policy/license_watch.ts index 195c8509e60d5..28a6eb273bcf3 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/policy/license_watch.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/policy/license_watch.ts @@ -101,6 +101,7 @@ export class PolicyWatcher { for (const policy of response.items as PolicyData[]) { const updatePolicy = getPolicyDataForUpdate(policy); const policyConfig = updatePolicy.inputs[0].config.policy.value; + updatePolicy.inputs[0].config.policy.value.meta.license = license.type || ''; try { if (!isEndpointPolicyValidForLicense(policyConfig, license)) { diff --git a/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.test.ts b/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.test.ts index f17c9595a6786..0ffdc09844c9a 100644 --- a/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.test.ts +++ b/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.test.ts @@ -92,13 +92,13 @@ describe('ingest_integration tests ', () => { const soClient = savedObjectsClientMock.create(); const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - const createNewEndpointPolicyInput = (manifest: ManifestSchema) => ({ + const createNewEndpointPolicyInput = (manifest: ManifestSchema, license = 'platinum') => ({ type: 'endpoint', enabled: true, streams: [], config: { integration_config: {}, - policy: { value: disableProtections(policyFactory()) }, + policy: { value: disableProtections(policyFactory(license)) }, artifact_manifest: { value: manifest }, }, }); diff --git a/x-pack/plugins/security_solution/server/fleet_integration/handlers/create_default_policy.test.ts b/x-pack/plugins/security_solution/server/fleet_integration/handlers/create_default_policy.test.ts index a385a37e78b61..c0b713b1bfa17 100644 --- a/x-pack/plugins/security_solution/server/fleet_integration/handlers/create_default_policy.test.ts +++ b/x-pack/plugins/security_solution/server/fleet_integration/handlers/create_default_policy.test.ts @@ -169,6 +169,8 @@ describe('Create Default Policy tests ', () => { const config = createEndpointConfig({ preset: 'EDRComplete' }); const policy = createDefaultPolicyCallback(config); const defaultPolicy = policyFactory(); + // update defaultPolicy w/ platinum license + defaultPolicy.meta.license = 'platinum'; expect(policy).toMatchObject(defaultPolicy); }); }); diff --git a/x-pack/plugins/security_solution/server/fleet_integration/handlers/create_default_policy.ts b/x-pack/plugins/security_solution/server/fleet_integration/handlers/create_default_policy.ts index 233abc25d0357..17b1972b743d6 100644 --- a/x-pack/plugins/security_solution/server/fleet_integration/handlers/create_default_policy.ts +++ b/x-pack/plugins/security_solution/server/fleet_integration/handlers/create_default_policy.ts @@ -29,6 +29,9 @@ export const createDefaultPolicy = ( ): PolicyConfig => { const factoryPolicy = policyConfigFactory(); + // Add license information after policy creation + factoryPolicy.meta.license = licenseService.getLicenseType(); + const defaultPolicyPerType = config?.type === 'cloud' ? getCloudPolicyConfig(factoryPolicy)