[Defend Workflows] Artifact Rollout Note field (#164838)

This PR is a second part of Artifact Rollout Epic
(https://github.com/elastic/security-team/issues/3593) and it introduces
**Note** field as described in
https://github.com/elastic/security-team/issues/7238 ticket.

Changes:
1. Added a new SO, `policy-settings-protection-updates-note` which holds
non indexable `note` field of type **text** and reference to package
policy
2. Added `getPackagePolicyDeleteCallback` that cleans up SO on package
policy deletion
3. Exposed an API to interact with the SO (POST, GET) with
POST method accepting both creation and update if SO exists.
4. Integrated UI with API with `react-query` hooks.

Flow


359d59bd-1bde-417a-9449-467d08e81809

Read only access

![Screenshot 2023-08-29 at 13 11
58](36df0b40-6012-45a8-aa7d-c2fa0527e594)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Konrad Szwarc 2023-09-06 07:16:29 +02:00 committed by GitHub
parent c9958485fa
commit e315c9c175
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 780 additions and 16 deletions

View file

@ -616,6 +616,10 @@
}
}
},
"apm-indices": {
"dynamic": false,
"properties": {}
},
"tag": {
"properties": {
"name": {
@ -2987,6 +2991,14 @@
}
}
},
"policy-settings-protection-updates-note": {
"properties": {
"note": {
"type": "text",
"index": false
}
}
},
"infrastructure-ui-source": {
"dynamic": false,
"properties": {}
@ -3031,10 +3043,6 @@
}
}
},
"apm-indices": {
"dynamic": false,
"properties": {}
},
"apm-telemetry": {
"dynamic": false,
"properties": {}

View file

@ -125,6 +125,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
"osquery-pack": "6ab4358ca4304a12dcfc1777c8135b75cffb4397",
"osquery-pack-asset": "b14101d3172c4b60eb5404696881ce5275c84152",
"osquery-saved-query": "44f1161e165defe3f9b6ad643c68c542a765fcdb",
"policy-settings-protection-updates-note": "33924bb246f9e5bcb876109cc83e3c7a28308352",
"query": "21cbbaa09abb679078145ce90087b1e88b7eae95",
"risk-engine-configuration": "b105d4a3c6adce40708d729d12e5ef3c8fbd9508",
"rules-settings": "892a2918ebaeba809a612b8d97cec0b07c800b5f",

View file

@ -96,6 +96,7 @@ const previouslyRegisteredTypes = [
'osquery-saved-query',
'osquery-usage-metric',
'osquery-manager-usage-metric',
'policy-settings-protection-updates-note',
'query',
'rules-settings',
'sample-data-telemetry',

View file

@ -245,6 +245,7 @@ describe('split .kibana index into multiple system indices', () => {
"osquery-pack",
"osquery-pack-asset",
"osquery-saved-query",
"policy-settings-protection-updates-note",
"query",
"risk-engine-configuration",
"rules-settings",

View file

@ -9,6 +9,7 @@ import { i18n } from '@kbn/i18n';
import type { SubFeatureConfig } from '@kbn/features-plugin/common';
import { EXCEPTION_LIST_NAMESPACE_AGNOSTIC } from '@kbn/securitysolution-list-constants';
import { AppFeaturesPrivilegeId, AppFeaturesPrivileges } from '../app_features_privileges';
import { SecuritySubFeatureId } from '../app_features_keys';
import { APP_ID } from '../constants';
import type { SecurityFeatureParams } from './types';
@ -320,7 +321,7 @@ const policyManagementSubFeature: SubFeatureConfig = {
includeIn: 'none',
name: 'All',
savedObject: {
all: [],
all: ['policy-settings-protection-updates-note'],
read: [],
},
ui: ['writePolicyManagement', 'readPolicyManagement'],
@ -332,7 +333,7 @@ const policyManagementSubFeature: SubFeatureConfig = {
name: 'Read',
savedObject: {
all: [],
read: [],
read: ['policy-settings-protection-updates-note'],
},
ui: ['readPolicyManagement'],
},

View file

@ -0,0 +1,23 @@
/*
* 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 { schema } from '@kbn/config-schema';
export const GetProtectionUpdatesNoteSchema = {
params: schema.object({
package_policy_id: schema.string(),
}),
};
export const CreateUpdateProtectionUpdatesNoteSchema = {
body: schema.object({
note: schema.string(),
}),
params: schema.object({
package_policy_id: schema.string(),
}),
};

View file

@ -63,6 +63,7 @@ export const METADATA_TRANSFORMS_STATUS_ROUTE = `${BASE_ENDPOINT_ROUTE}/metadata
export const BASE_POLICY_RESPONSE_ROUTE = `${BASE_ENDPOINT_ROUTE}/policy_response`;
export const BASE_POLICY_ROUTE = `${BASE_ENDPOINT_ROUTE}/policy`;
export const AGENT_POLICY_SUMMARY_ROUTE = `${BASE_POLICY_ROUTE}/summaries`;
export const PROTECTION_UPDATES_NOTE_ROUTE = `${BASE_ENDPOINT_ROUTE}/protection_updates_note/{package_policy_id}`;
/** Suggestions routes */
export const SUGGESTIONS_ROUTE = `${BASE_ENDPOINT_ROUTE}/suggestions/{suggestion_type}`;

View file

@ -9,7 +9,10 @@ import moment from 'moment/moment';
import type { IndexedFleetEndpointPolicyResponse } from '../../../../../common/endpoint/data_loaders/index_fleet_endpoint_policy';
import type { PolicyData } from '../../../../../common/endpoint/types';
import { createAgentPolicyTask, getEndpointIntegrationVersion } from '../../tasks/fleet';
import { setCustomProtectionUpdatesManifestVersion } from '../../tasks/endpoint_policy';
import {
setCustomProtectionUpdatesManifestVersion,
setCustomProtectionUpdatesNote,
} from '../../tasks/endpoint_policy';
import { login, ROLE } from '../../tasks/login';
import { disableExpandableFlyoutAdvancedSettings, loadPage } from '../../tasks/common';
@ -17,6 +20,8 @@ describe('Policy Details', () => {
describe('Protection updates', () => {
const loadProtectionUpdatesUrl = (policyId: string) =>
loadPage(`/app/security/administration/policy/${policyId}/protectionUpdates`);
const testNote = 'test note';
const updatedTestNote = 'updated test note';
describe('Renders and saves protection updates', () => {
let indexedPolicy: IndexedFleetEndpointPolicyResponse;
@ -59,13 +64,18 @@ describe('Policy Details', () => {
cy.getByTestSubj('protection-updates-version-to-deploy-picker').within(() => {
cy.get('input').should('have.value', formattedToday);
});
cy.getByTestSubj('protection-updates-manifest-name-note-title');
cy.getByTestSubj('protection-updates-manifest-note');
cy.getByTestSubj('policyDetailsSaveButton');
});
it('should successfully update the manifest version to custom date', () => {
loadProtectionUpdatesUrl(policy.id);
cy.getByTestSubj('protection-updates-manifest-switch').click();
cy.getByTestSubj('protection-updates-manifest-note').type(testNote);
cy.intercept('PUT', `/api/fleet/package_policies/${policy.id}`).as('policy');
cy.intercept('POST', `/api/endpoint/protection_updates_note/*`).as('note');
cy.getByTestSubj('policyDetailsSaveButton').click();
cy.wait('@policy').then(({ request, response }) => {
expect(request.body.inputs[0].config.policy.value.global_manifest_version).to.equal(
@ -73,8 +83,15 @@ describe('Policy Details', () => {
);
expect(response?.statusCode).to.equal(200);
});
cy.wait('@note').then(({ request, response }) => {
expect(request.body.note).to.equal(testNote);
expect(response?.statusCode).to.equal(200);
});
cy.getByTestSubj('protectionUpdatesSuccessfulMessage');
cy.getByTestSubj('protection-updates-deployed-version').contains(formattedToday);
cy.getByTestSubj('protection-updates-manifest-note').contains(testNote);
});
});
@ -122,6 +139,50 @@ describe('Policy Details', () => {
});
});
describe('Renders and saves protection updates with custom note', () => {
let indexedPolicy: IndexedFleetEndpointPolicyResponse;
let policy: PolicyData;
const twoMonthsAgo = moment().subtract(2, 'months').format('YYYY-MM-DD');
beforeEach(() => {
login();
disableExpandableFlyoutAdvancedSettings();
});
before(() => {
getEndpointIntegrationVersion().then((version) => {
createAgentPolicyTask(version).then((data) => {
indexedPolicy = data;
policy = indexedPolicy.integrationPolicies[0];
setCustomProtectionUpdatesManifestVersion(policy.id, twoMonthsAgo);
setCustomProtectionUpdatesNote(policy.id, testNote);
});
});
});
after(() => {
if (indexedPolicy) {
cy.task('deleteIndexedFleetEndpointPolicies', indexedPolicy);
}
});
it('should update note on save', () => {
loadProtectionUpdatesUrl(policy.id);
cy.getByTestSubj('protection-updates-manifest-note').contains(testNote);
cy.getByTestSubj('protection-updates-manifest-note').clear().type(updatedTestNote);
cy.intercept('POST', `/api/endpoint/protection_updates_note/*`).as('note_updated');
cy.getByTestSubj('policyDetailsSaveButton').click();
cy.wait('@note_updated').then(({ request, response }) => {
expect(request.body.note).to.equal(updatedTestNote);
expect(response?.statusCode).to.equal(200);
});
cy.getByTestSubj('protectionUpdatesSuccessfulMessage');
cy.getByTestSubj('protection-updates-manifest-note').contains(updatedTestNote);
});
});
describe('Renders read only protection updates for user without write permissions', () => {
let indexedPolicy: IndexedFleetEndpointPolicyResponse;
let policy: PolicyData;
@ -138,6 +199,7 @@ describe('Policy Details', () => {
indexedPolicy = data;
policy = indexedPolicy.integrationPolicies[0];
setCustomProtectionUpdatesManifestVersion(policy.id, twoMonthsAgo.format('YYYY-MM-DD'));
setCustomProtectionUpdatesNote(policy.id, testNote);
});
});
});
@ -162,6 +224,10 @@ describe('Policy Details', () => {
cy.getByTestSubj('protection-updates-manifest-name-version-to-deploy-title');
cy.getByTestSubj('protection-updates-version-to-deploy-view-mode');
cy.getByTestSubj('protection-updates-version-to-deploy-picker').should('not.exist');
cy.getByTestSubj('protection-updates-manifest-name-note-title');
cy.getByTestSubj('protection-updates-manifest-note').should('not.exist');
cy.getByTestSubj('protection-updates-manifest-note-view-mode').contains(testNote);
cy.getByTestSubj('policyDetailsSaveButton').should('be.disabled');
});
});

View file

@ -94,3 +94,15 @@ export const setCustomProtectionUpdatesManifestVersion = (
});
});
};
export const setCustomProtectionUpdatesNote = (
endpointPolicyId: string,
note: string
): Cypress.Chainable<Cypress.Response<{ note: string }>> => {
return request<{ note: string }>({
method: 'POST',
url: `/api/endpoint/protection_updates_note/${endpointPolicyId}`,
body: { note },
headers: { 'Elastic-Api-Version': '2023-10-31' },
});
};

View file

@ -0,0 +1,42 @@
/*
* 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 { useQuery } from '@tanstack/react-query';
import { resolvePathVariables } from '../../../../../../common/utils/resolve_path_variables';
import { PROTECTION_UPDATES_NOTE_ROUTE } from '../../../../../../../common/endpoint/constants';
import { useKibana } from '../../../../../../common/lib/kibana';
export const getProtectionUpdatesNoteQueryKey = (packagePolicyId: string) =>
`protection-updates-note-${packagePolicyId}`;
interface UseProtectionUpdatesNote {
packagePolicyId: string;
}
interface NoteResponse {
note: string;
}
export const useGetProtectionUpdatesNote = ({ packagePolicyId }: UseProtectionUpdatesNote) => {
const { http } = useKibana().services;
return useQuery<{ data: NoteResponse }, unknown, NoteResponse>(
[getProtectionUpdatesNoteQueryKey(packagePolicyId)],
() =>
http.get(
resolvePathVariables(PROTECTION_UPDATES_NOTE_ROUTE, { package_policy_id: packagePolicyId }),
{
version: '2023-10-31',
}
),
{
keepPreviousData: true,
enabled: !!packagePolicyId,
retry: false,
}
);
};

View file

@ -0,0 +1,47 @@
/*
* 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 { useMutation, useQueryClient } from '@tanstack/react-query';
import { getProtectionUpdatesNoteQueryKey } from './use_get_protection_updates_note';
import { useKibana } from '../../../../../../common/lib/kibana';
import { resolvePathVariables } from '../../../../../../common/utils/resolve_path_variables';
import { PROTECTION_UPDATES_NOTE_ROUTE } from '../../../../../../../common/endpoint/constants';
interface ProtectionUpdatesNoteParams {
packagePolicyId: string;
}
interface NoteResponse {
note: string;
}
export const useCreateProtectionUpdatesNote = ({
packagePolicyId,
}: ProtectionUpdatesNoteParams) => {
const { http } = useKibana().services;
const queryClient = useQueryClient();
return useMutation<
{ data: NoteResponse },
{ body: { error: string; message: string } },
NoteResponse
>(
(payload) =>
http.post(
resolvePathVariables(PROTECTION_UPDATES_NOTE_ROUTE, { policy_id: packagePolicyId }),
{
version: '2023-10-31',
body: JSON.stringify(payload),
}
),
{
onSuccess: () => {
queryClient.invalidateQueries([getProtectionUpdatesNoteQueryKey(packagePolicyId)]);
},
}
);
};

View file

@ -13,14 +13,16 @@ import {
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiIconTip,
EuiPanel,
EuiShowFor,
EuiSpacer,
EuiSwitch,
EuiText,
EuiTextArea,
EuiTitle,
} from '@elastic/eui';
import React, { useCallback, useContext, useState } from 'react';
import React, { useCallback, useContext, useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import { ThemeContext } from 'styled-components';
import { i18n } from '@kbn/i18n';
@ -28,6 +30,8 @@ import { FormattedMessage } from '@kbn/i18n-react';
import type { Moment } from 'moment';
import moment from 'moment';
import { cloneDeep } from 'lodash';
import { useCreateProtectionUpdatesNote } from './hooks/use_post_protection_updates_note';
import { useGetProtectionUpdatesNote } from './hooks/use_get_protection_updates_note';
import { useUserPrivileges } from '../../../../../common/components/user_privileges';
import { useToasts } from '../../../../../common/lib/kibana';
import { useUpdateEndpointPolicy } from '../../../../hooks/policy/use_update_endpoint_policy';
@ -67,6 +71,20 @@ export const ProtectionUpdatesLayout = React.memo<ProtectionUpdatesLayoutProps>(
const today = moment();
const [selectedDate, setSelectedDate] = useState<Moment>(today);
const { data: fetchedNote, isLoading: getNoteInProgress } = useGetProtectionUpdatesNote({
packagePolicyId: _policy.id,
});
const { isLoading: createNoteInProgress, mutate: createNote } = useCreateProtectionUpdatesNote({
packagePolicyId: _policy.id,
});
const [note, setNote] = useState('');
useEffect(() => {
if (fetchedNote && !getNoteInProgress) {
setNote(fetchedNote.note);
}
}, [fetchedNote, getNoteInProgress]);
const automaticUpdatesEnabled = manifestVersion === 'latest';
const internalDateFormat = 'YYYY-MM-DD';
const displayDateFormat = 'MMMM DD, YYYY';
@ -119,8 +137,27 @@ export const ProtectionUpdatesLayout = React.memo<ProtectionUpdatesLayoutProps>(
text: err.message,
});
});
if ((!fetchedNote && note !== '') || (fetchedNote && note !== fetchedNote.note)) {
createNote(
{ note },
{
onError: (error) => {
toasts.addDanger({
'data-test-subj': 'protectionUpdatesNoteUpdateFailureMessage',
title: i18n.translate(
'xpack.securitySolution.endpoint.protectionUpdates.noteUpdateErrorTitle',
{
defaultMessage: 'Note update failed!',
}
),
text: error.body.message,
});
},
}
);
}
},
[dispatch, policy, sendPolicyUpdate, toasts]
[policy, sendPolicyUpdate, fetchedNote, note, toasts, dispatch, createNote]
);
const toggleAutomaticUpdates = useCallback(
@ -260,16 +297,57 @@ export const ProtectionUpdatesLayout = React.memo<ProtectionUpdatesLayoutProps>(
)}
</h5>
</EuiTitle>
<EuiSpacer size="m" />
<EuiSpacer size="m" />
<EuiText size="m" data-test-subj="protection-updates-deployed-version">
{deployedVersion === 'latest' ? 'latest' : formattedDate}
</EuiText>
<EuiSpacer size="l" />
{renderVersionToDeployPicker()}
<EuiSpacer size="m" />
<EuiFlexGroup direction="row" gutterSize="none" alignItems="center">
<EuiTitle size="xxs" data-test-subj={'protection-updates-manifest-name-note-title'}>
<h5>
{i18n.translate('xpack.securitySolution.endpoint.protectionUpdates.note.label', {
defaultMessage: 'Note',
})}
</h5>
</EuiTitle>
<EuiIconTip
position="right"
content={
<>
<FormattedMessage
id="xpack.securitySolution.endpoint.protectionUpdates.note.tooltip"
defaultMessage="You can add an optional note to explain the reason for selecting a particular policy version."
/>
</>
}
/>
</EuiFlexGroup>
<EuiSpacer size="m" />
{canWritePolicyManagement ? (
<EuiTextArea
value={note}
disabled={getNoteInProgress || createNoteInProgress}
onChange={(e) => setNote(e.target.value)}
fullWidth={true}
rows={3}
placeholder={i18n.translate(
'xpack.securitySolution.endpoint.protectionUpdates.note.placeholder',
{
defaultMessage: 'Add relevant information about update here',
}
)}
data-test-subj={'protection-updates-manifest-note'}
/>
) : (
<EuiText data-test-subj={'protection-updates-manifest-note-view-mode'}>{note}</EuiText>
)}
<EuiSpacer size="m" />
<EuiButton
fill={true}
disabled={!canWritePolicyManagement}
@ -315,7 +393,7 @@ export const ProtectionUpdatesLayout = React.memo<ProtectionUpdatesLayoutProps>(
<EuiShowFor sizes={['l', 'xl', 'm']}>
{canWritePolicyManagement ? (
<EuiSwitch
disabled={isUpdating}
disabled={isUpdating || createNoteInProgress || getNoteInProgress}
label={'Update manifest automatically'}
labelProps={{ 'data-test-subj': 'protection-updates-manifest-switch-label' }}
checked={automaticUpdatesEnabled}

View file

@ -5,7 +5,12 @@
* 2.0.
*/
import type { KibanaRequest, Logger, ElasticsearchClient } from '@kbn/core/server';
import type {
KibanaRequest,
Logger,
ElasticsearchClient,
SavedObjectsClientContract,
} from '@kbn/core/server';
import type { ExceptionListClient, ListsServerExtensionRegistrar } from '@kbn/lists-plugin/server';
import type { CasesClient, CasesStart } from '@kbn/cases-plugin/server';
import type { SecurityPluginStart } from '@kbn/security-plugin/server';
@ -71,6 +76,7 @@ export interface EndpointAppContextServiceStartContract {
actionCreateService: ActionCreateService | undefined;
esClient: ElasticsearchClient;
appFeaturesService: AppFeaturesService;
savedObjectsClient: SavedObjectsClientContract;
}
/**
@ -108,6 +114,7 @@ export class EndpointAppContextService {
endpointMetadataService,
esClient,
appFeaturesService,
savedObjectsClient,
} = dependencies;
registerIngestCallback(
@ -144,7 +151,7 @@ export class EndpointAppContextService {
registerIngestCallback(
'packagePolicyPostDelete',
getPackagePolicyDeleteCallback(exceptionListsClient)
getPackagePolicyDeleteCallback(exceptionListsClient, savedObjectsClient)
);
}

View file

@ -0,0 +1,28 @@
/*
* 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 { SavedObjectsType } from '@kbn/core-saved-objects-server';
import { SECURITY_SOLUTION_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server';
export const protectionUpdatesNoteSavedObjectType = 'policy-settings-protection-updates-note';
export const protectionUpdatesNoteSavedObjectMappings: SavedObjectsType['mappings'] = {
properties: {
note: {
type: 'text',
index: false,
},
},
};
export const protectionUpdatesNoteType: SavedObjectsType = {
name: protectionUpdatesNoteSavedObjectType,
indexPattern: SECURITY_SOLUTION_SAVED_OBJECT_INDEX,
hidden: false,
namespaceType: 'single',
mappings: protectionUpdatesNoteSavedObjectMappings,
};

View file

@ -221,6 +221,7 @@ export const createMockEndpointAppContextServiceStartContract =
createFleetActionsClient: jest.fn((_) => fleetActionsClientMock),
esClient: elasticsearchClientMock.createElasticsearchClient(),
appFeaturesService,
savedObjectsClient: savedObjectsClientMock.create(),
};
};

View file

@ -0,0 +1,191 @@
/*
* 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 { EndpointAppContextService } from '../../endpoint_app_context_services';
import type { KibanaResponseFactory, SavedObjectsClientContract } from '@kbn/core/server';
import {
createMockEndpointAppContextServiceSetupContract,
createMockEndpointAppContextServiceStartContract,
createRouteHandlerContext,
} from '../../mocks';
import type { ScopedClusterClientMock } from '@kbn/core/server/mocks';
import {
elasticsearchServiceMock,
httpServerMock,
savedObjectsClientMock,
} from '@kbn/core/server/mocks';
import { getProtectionUpdatesNoteHandler, postProtectionUpdatesNoteHandler } from './handlers';
import { requestContextMock } from '../../../lib/detection_engine/routes/__mocks__';
const mockedSOSuccessfulFindResponse = {
total: 1,
saved_objects: [
{
id: 'id',
type: 'type',
references: [
{
id: 'id_package_policy',
name: 'package_policy',
type: 'ingest-package-policies',
},
],
attributes: { note: 'note' },
score: 1,
},
],
page: 1,
per_page: 10,
};
const mockedSOSuccessfulFindResponseEmpty = {
total: 0,
saved_objects: [],
page: 1,
per_page: 10,
};
const createMockedSOSuccessfulCreateResponse = (note: string) => ({
id: 'id',
type: 'type',
references: [],
attributes: { note },
});
const mockedSOSuccessfulUpdateResponse = [
'policy-settings-protection-updates-note',
'id',
{ note: 'note2' },
{
references: [
{
id: 'id_package_policy',
name: 'package_policy',
type: 'ingest-package-policies',
},
],
refresh: 'wait_for',
},
];
describe('test protection updates note handler', () => {
let endpointAppContextService: EndpointAppContextService;
let mockSavedObjectClient: jest.Mocked<SavedObjectsClientContract>;
let mockResponse: jest.Mocked<KibanaResponseFactory>;
let mockScopedClient: ScopedClusterClientMock;
describe('test protection updates note handler', () => {
beforeEach(() => {
mockScopedClient = elasticsearchServiceMock.createScopedClusterClient();
mockSavedObjectClient = savedObjectsClientMock.create();
mockResponse = httpServerMock.createResponseFactory();
endpointAppContextService = new EndpointAppContextService();
endpointAppContextService.setup(createMockEndpointAppContextServiceSetupContract());
endpointAppContextService.start(createMockEndpointAppContextServiceStartContract());
});
afterEach(() => endpointAppContextService.stop());
it('should create a new note if one does not exist', async () => {
const protectionUpdatesNoteHandler = postProtectionUpdatesNoteHandler();
const mockRequest = httpServerMock.createKibanaRequest({
params: { policyId: 'id' },
body: { note: 'note' },
});
mockSavedObjectClient.find.mockResolvedValueOnce(mockedSOSuccessfulFindResponseEmpty);
mockSavedObjectClient.create.mockResolvedValueOnce(
createMockedSOSuccessfulCreateResponse('note')
);
await protectionUpdatesNoteHandler(
requestContextMock.convertContext(
createRouteHandlerContext(mockScopedClient, mockSavedObjectClient)
),
mockRequest,
mockResponse
);
expect(mockResponse.ok).toBeCalled();
expect(mockSavedObjectClient.create).toBeCalledWith(
'policy-settings-protection-updates-note',
{ note: 'note' },
{
references: [{ id: undefined, name: 'package_policy', type: 'ingest-package-policies' }],
refresh: 'wait_for',
}
);
});
it('should update an existing note on post if one exists', async () => {
const protectionUpdatesNoteHandler = postProtectionUpdatesNoteHandler();
const mockRequest = httpServerMock.createKibanaRequest({
params: { policyId: 'id' },
body: { note: 'note2' },
});
mockSavedObjectClient.find.mockResolvedValueOnce(mockedSOSuccessfulFindResponse);
mockSavedObjectClient.update.mockResolvedValueOnce(
createMockedSOSuccessfulCreateResponse('note2')
);
await protectionUpdatesNoteHandler(
requestContextMock.convertContext(
createRouteHandlerContext(mockScopedClient, mockSavedObjectClient)
),
mockRequest,
mockResponse
);
expect(mockResponse.ok).toBeCalled();
expect(mockSavedObjectClient.update).toBeCalledWith(...mockedSOSuccessfulUpdateResponse);
});
it('should return the note if one exists', async () => {
const protectionUpdatesNoteHandler = getProtectionUpdatesNoteHandler();
const mockRequest = httpServerMock.createKibanaRequest({
params: { policyId: 'id' },
});
mockSavedObjectClient.find.mockResolvedValueOnce(mockedSOSuccessfulFindResponse);
await protectionUpdatesNoteHandler(
requestContextMock.convertContext(
createRouteHandlerContext(mockScopedClient, mockSavedObjectClient)
),
mockRequest,
mockResponse
);
expect(mockResponse.ok).toBeCalled();
const result = mockResponse.ok.mock.calls[0][0]?.body as { note: string };
expect(result.note).toEqual('note');
});
it('should return notFound if no note exists', async () => {
const protectionUpdatesNoteHandler = getProtectionUpdatesNoteHandler();
const mockRequest = httpServerMock.createKibanaRequest({
params: { policyId: 'id' },
});
mockSavedObjectClient.find.mockResolvedValueOnce(mockedSOSuccessfulFindResponseEmpty);
await protectionUpdatesNoteHandler(
requestContextMock.convertContext(
createRouteHandlerContext(mockScopedClient, mockSavedObjectClient)
),
mockRequest,
mockResponse
);
expect(mockResponse.notFound).toBeCalled();
});
});
});

View file

@ -0,0 +1,126 @@
/*
* 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 {
RequestHandler,
SavedObjectReference,
SavedObjectsClientContract,
} from '@kbn/core/server';
import type { TypeOf } from '@kbn/config-schema';
import { PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '@kbn/fleet-plugin/common';
import { protectionUpdatesNoteSavedObjectType } from '../../lib/protection_updates_note/saved_object_mappings';
import type {
CreateUpdateProtectionUpdatesNoteSchema,
GetProtectionUpdatesNoteSchema,
} from '../../../../common/api/endpoint/protection_updates_note/protection_updates_note_schema';
const getProtectionNote = async (SOClient: SavedObjectsClientContract, packagePolicyId: string) => {
return SOClient.find<{ note: string }>({
type: protectionUpdatesNoteSavedObjectType,
hasReference: { type: PACKAGE_POLICY_SAVED_OBJECT_TYPE, id: packagePolicyId },
});
};
const updateProtectionNote = async (
SOClient: SavedObjectsClientContract,
noteId: string,
note: string,
references: SavedObjectReference[]
) => {
return SOClient.update(
protectionUpdatesNoteSavedObjectType,
noteId,
{
note,
},
{
references,
refresh: 'wait_for',
}
);
};
const createProtectionNote = async (
SOClient: SavedObjectsClientContract,
note: string,
references: SavedObjectReference[]
) => {
return SOClient.create(
protectionUpdatesNoteSavedObjectType,
{
note,
},
{
references,
refresh: 'wait_for',
}
);
};
export const postProtectionUpdatesNoteHandler = function (): RequestHandler<
TypeOf<typeof CreateUpdateProtectionUpdatesNoteSchema.params>,
undefined,
TypeOf<typeof CreateUpdateProtectionUpdatesNoteSchema.body>
> {
return async (context, request, response) => {
const SOClient = (await context.core).savedObjects.client;
const { package_policy_id: packagePolicyId } = request.params;
const { note } = request.body;
const soClientResponse = await getProtectionNote(SOClient, packagePolicyId);
if (soClientResponse.saved_objects[0]) {
const { references } = soClientResponse.saved_objects[0];
const updatedNoteSO = await updateProtectionNote(
SOClient,
soClientResponse.saved_objects[0].id,
note,
references
);
const { attributes } = updatedNoteSO;
return response.ok({ body: attributes });
}
const references: SavedObjectReference[] = [
{
id: packagePolicyId,
name: 'package_policy',
type: PACKAGE_POLICY_SAVED_OBJECT_TYPE,
},
];
const noteSO = await createProtectionNote(SOClient, note, references);
const { attributes } = noteSO;
return response.ok({ body: attributes });
};
};
export const getProtectionUpdatesNoteHandler = function (): RequestHandler<
TypeOf<typeof GetProtectionUpdatesNoteSchema.params>,
undefined,
undefined
> {
return async (context, request, response) => {
const SOClient = (await context.core).savedObjects.client;
const { package_policy_id: packagePolicyId } = request.params;
const soClientResponse = await getProtectionNote(SOClient, packagePolicyId);
if (!soClientResponse.saved_objects[0] || !soClientResponse.saved_objects[0].attributes) {
return response.notFound({ body: { message: 'No note found for this policy' } });
}
const { attributes } = soClientResponse.saved_objects[0];
return response.ok({ body: attributes });
};
};

View file

@ -0,0 +1,63 @@
/*
* 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 { IRouter } from '@kbn/core/server';
import { getProtectionUpdatesNoteHandler, postProtectionUpdatesNoteHandler } from './handlers';
import {
GetProtectionUpdatesNoteSchema,
CreateUpdateProtectionUpdatesNoteSchema,
} from '../../../../common/api/endpoint/protection_updates_note/protection_updates_note_schema';
import { withEndpointAuthz } from '../with_endpoint_authz';
import { PROTECTION_UPDATES_NOTE_ROUTE } from '../../../../common/endpoint/constants';
import type { EndpointAppContext } from '../../types';
export function registerProtectionUpdatesNoteRoutes(
router: IRouter,
endpointAppContext: EndpointAppContext
) {
const logger = endpointAppContext.logFactory.get('protectionUpdatesNote');
router.versioned
.post({
access: 'public',
path: PROTECTION_UPDATES_NOTE_ROUTE,
options: { authRequired: true, tags: ['access:securitySolution'] },
})
.addVersion(
{
version: '2023-10-31',
validate: {
request: CreateUpdateProtectionUpdatesNoteSchema,
},
},
withEndpointAuthz(
{ all: ['canWritePolicyManagement'] },
logger,
postProtectionUpdatesNoteHandler()
)
);
router.versioned
.get({
access: 'public',
path: PROTECTION_UPDATES_NOTE_ROUTE,
options: { authRequired: true, tags: ['access:securitySolution'] },
})
.addVersion(
{
version: '2023-10-31',
validate: {
request: GetProtectionUpdatesNoteSchema,
},
},
withEndpointAuthz(
{ all: ['canReadPolicyManagement'] },
logger,
getProtectionUpdatesNoteHandler()
)
);
}

View file

@ -615,7 +615,7 @@ describe('ingest_integration tests ', () => {
const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
const invokeDeleteCallback = async (): Promise<void> => {
const callback = getPackagePolicyDeleteCallback(exceptionListClient);
const callback = getPackagePolicyDeleteCallback(exceptionListClient, soClient);
await callback(deletePackagePolicyMock(), soClient, esClient);
};
@ -640,6 +640,27 @@ describe('ingest_integration tests ', () => {
});
it('removes policy from artifact', async () => {
soClient.find.mockResolvedValueOnce({
total: 1,
saved_objects: [
{
id: 'id',
type: 'type',
references: [
{
id: 'id_package_policy',
name: 'package_policy',
type: 'ingest-package-policies',
},
],
attributes: { note: 'note' },
score: 1,
},
],
page: 1,
per_page: 10,
});
await invokeDeleteCallback();
expect(exceptionListClient.findExceptionListsItem).toHaveBeenCalledWith({
@ -660,6 +681,8 @@ describe('ingest_integration tests ', () => {
osTypes: fakeArtifact.os_types,
tags: [],
});
expect(soClient.delete).toBeCalledWith('policy-settings-protection-updates-note', 'id');
});
});
});

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import type { Logger, ElasticsearchClient } from '@kbn/core/server';
import type { Logger, ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/server';
import type { ExceptionListClient } from '@kbn/lists-plugin/server';
import type { PluginStartContract as AlertsStartContract } from '@kbn/alerting-plugin/server';
import type {
@ -44,6 +44,7 @@ import type { AnyPolicyCreateConfig } from './types';
import { ENDPOINT_INTEGRATION_CONFIG_KEY } from './constants';
import { createEventFilters } from './handlers/create_event_filters';
import type { AppFeaturesService } from '../lib/app_features_service/app_features_service';
import { removeProtectionUpdatesNote } from './handlers/remove_protection_updates_note';
const isEndpointPackagePolicy = <T extends { package?: { name: string } }>(
packagePolicy: T
@ -280,7 +281,8 @@ export const getPackagePolicyPostCreateCallback = (
};
export const getPackagePolicyDeleteCallback = (
exceptionsClient: ExceptionListClient | undefined
exceptionsClient: ExceptionListClient | undefined,
savedObjectsClient: SavedObjectsClientContract | undefined
): PostPackagePolicyPostDeleteCallback => {
return async (deletePackagePolicy): Promise<void> => {
if (!exceptionsClient) {
@ -290,8 +292,12 @@ export const getPackagePolicyDeleteCallback = (
for (const policy of deletePackagePolicy) {
if (isEndpointPackagePolicy(policy)) {
policiesToRemove.push(removePolicyFromArtifacts(exceptionsClient, policy));
if (savedObjectsClient) {
policiesToRemove.push(removeProtectionUpdatesNote(savedObjectsClient, policy));
}
}
}
await Promise.all(policiesToRemove);
};
};

View file

@ -0,0 +1,33 @@
/*
* 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 { PostPackagePolicyPostDeleteCallback } from '@kbn/fleet-plugin/server';
import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server';
import { PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '@kbn/fleet-plugin/common';
import pMap from 'p-map';
import { protectionUpdatesNoteSavedObjectType } from '../../endpoint/lib/protection_updates_note/saved_object_mappings';
export const removeProtectionUpdatesNote = async (
soClient: SavedObjectsClientContract,
policy: Parameters<PostPackagePolicyPostDeleteCallback>[0][0]
) => {
if (policy.id) {
const foundProtectionUpdatesNotes = await soClient.find({
type: protectionUpdatesNoteSavedObjectType,
hasReference: {
type: PACKAGE_POLICY_SAVED_OBJECT_TYPE,
id: policy.id,
},
});
await pMap(
foundProtectionUpdatesNotes.saved_objects,
(protectionUpdatesNote: { id: string }) => {
soClient.delete(protectionUpdatesNoteSavedObjectType, protectionUpdatesNote.id);
}
);
}
};

View file

@ -98,6 +98,7 @@ import {
import { AppFeaturesService } from './lib/app_features_service/app_features_service';
import { registerRiskScoringTask } from './lib/risk_engine/tasks/risk_scoring_task';
import { registerProtectionUpdatesNoteRoutes } from './endpoint/routes/protection_updates_note';
export type { SetupPlugins, StartPlugins, PluginSetup, PluginStart } from './plugin_contract';
@ -317,6 +318,7 @@ export class Plugin implements ISecuritySolutionPlugin {
);
registerLimitedConcurrencyRoutes(core);
registerPolicyRoutes(router, this.endpointContext);
registerProtectionUpdatesNoteRoutes(router, this.endpointContext);
registerActionRoutes(
router,
this.endpointContext,
@ -533,6 +535,7 @@ export class Plugin implements ISecuritySolutionPlugin {
createFleetActionsClient,
esClient: core.elasticsearch.client.asInternalUser,
appFeaturesService,
savedObjectsClient,
});
this.telemetryReceiver.start(

View file

@ -7,6 +7,7 @@
import type { CoreSetup } from '@kbn/core/server';
import { protectionUpdatesNoteType } from './endpoint/lib/protection_updates_note/saved_object_mappings';
import { noteType, pinnedEventType, timelineType } from './lib/timeline/saved_object_mappings';
// eslint-disable-next-line no-restricted-imports
import { legacyType as legacyRuleActionsType } from './lib/detection_engine/rule_actions_legacy';
@ -24,6 +25,7 @@ const types = [
manifestType,
signalsMigrationType,
riskEngineConfigurationType,
protectionUpdatesNoteType,
];
export const savedObjectTypes = types.map((type) => type.name);