From 2330c339ddce654aaec004af357ceb08c9487df6 Mon Sep 17 00:00:00 2001 From: Paul Tavares Date: Thu, 14 Jan 2021 09:51:11 -0500 Subject: [PATCH 01/31] Added sync_master file for tracking/triggering PRs for merging master into feature branch --- .../security_solution/public/management/sync_master.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 x-pack/plugins/security_solution/public/management/sync_master.md diff --git a/x-pack/plugins/security_solution/public/management/sync_master.md b/x-pack/plugins/security_solution/public/management/sync_master.md new file mode 100644 index 0000000000000..1757722839837 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/sync_master.md @@ -0,0 +1,8 @@ +# FEATURE: Artifacts by Policy Feature Branch + +This file sole purpose is to assist with merging of `elastic/kibana/master` into the feature branch `elastic/kibana/feature/endpoint-artifacts-by-policy` branch in order to ensure the feature branch remains in sync with `master` (and thus minimize the impact when merging back to `master`). + + + +## Last Merge of `master`: 2020.01.14 + From d3e19528337d0940e48573f664e6e8ef7fdeb8cc Mon Sep 17 00:00:00 2001 From: Paul Tavares Date: Thu, 14 Jan 2021 10:53:33 -0500 Subject: [PATCH 02/31] removed unnecessary (temporary) markdown file --- .../security_solution/public/management/sync_master.md | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 x-pack/plugins/security_solution/public/management/sync_master.md diff --git a/x-pack/plugins/security_solution/public/management/sync_master.md b/x-pack/plugins/security_solution/public/management/sync_master.md deleted file mode 100644 index 1757722839837..0000000000000 --- a/x-pack/plugins/security_solution/public/management/sync_master.md +++ /dev/null @@ -1,8 +0,0 @@ -# FEATURE: Artifacts by Policy Feature Branch - -This file sole purpose is to assist with merging of `elastic/kibana/master` into the feature branch `elastic/kibana/feature/endpoint-artifacts-by-policy` branch in order to ensure the feature branch remains in sync with `master` (and thus minimize the impact when merging back to `master`). - - - -## Last Merge of `master`: 2020.01.14 - From b3b63ea536f30c8c24e1f98444a73ba2ea58d33f Mon Sep 17 00:00:00 2001 From: Bohdan Tsymbala Date: Fri, 15 Jan 2021 15:57:36 +0100 Subject: [PATCH 03/31] Trusted apps by policy api (#88025) * Initial version of API for trusted apps per policy. * Fixed compilation errors because of missing new property. * Mapping from tags to policies and back. (No testing) * Fixed compilation error after pulling in main. * Fixed failing tests. * Separated out the prefix in tag for policy reference into constant. Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../endpoint/schema/trusted_apps.test.ts | 9 +++--- .../common/endpoint/schema/trusted_apps.ts | 32 +++++++++++++++++-- .../common/endpoint/types/trusted_apps.ts | 12 +++++++ .../pages/trusted_apps/store/builders.ts | 1 + .../pages/trusted_apps/test_utils/index.ts | 1 + .../create_trusted_app_form.test.tsx | 10 +++--- .../pages/trusted_apps/view/translations.ts | 3 ++ .../view/trusted_apps_page.test.tsx | 1 + .../routes/trusted_apps/handlers.test.ts | 15 ++++++--- .../routes/trusted_apps/mapping.test.ts | 19 +++++++++-- .../endpoint/routes/trusted_apps/mapping.ts | 30 ++++++++++++++++- .../routes/trusted_apps/service.test.ts | 12 +++++-- 12 files changed, 125 insertions(+), 20 deletions(-) diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts index e9ae439d0ac8c..74cfeb73d56e4 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts @@ -6,7 +6,7 @@ */ import { GetTrustedAppsRequestSchema, PostTrustedAppCreateRequestSchema } from './trusted_apps'; -import { ConditionEntryField, OperatingSystem } from '../types'; +import { ConditionEntry, ConditionEntryField, NewTrustedApp, OperatingSystem } from '../types'; describe('When invoking Trusted Apps Schema', () => { describe('for GET List', () => { @@ -72,17 +72,18 @@ describe('When invoking Trusted Apps Schema', () => { }); describe('for POST Create', () => { - const createConditionEntry = (data?: T) => ({ + const createConditionEntry = (data?: T): ConditionEntry => ({ field: ConditionEntryField.PATH, type: 'match', operator: 'included', value: 'c:/programs files/Anti-Virus', ...(data || {}), }); - const createNewTrustedApp = (data?: T) => ({ + const createNewTrustedApp = (data?: T): NewTrustedApp => ({ name: 'Some Anti-Virus App', description: 'this one is ok', - os: 'windows', + os: OperatingSystem.WINDOWS, + effectScope: { type: 'global' }, entries: [createConditionEntry()], ...(data || {}), }); diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts index 6d40dc75fd1c1..fb2a53d1cf919 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts @@ -5,8 +5,8 @@ * 2.0. */ -import { schema } from '@kbn/config-schema'; -import { ConditionEntryField, OperatingSystem } from '../types'; +import { schema, Type } from '@kbn/config-schema'; +import { ConditionEntry, ConditionEntryField, OperatingSystem } from '../types'; import { getDuplicateFields, isValidHash } from '../validation/trusted_apps'; export const DeleteTrustedAppsRequestSchema = { @@ -107,6 +107,34 @@ const EntriesSchema = schema.arrayOf(EntrySchemaDependingOnOS, { ); }, }); +const createNewTrustedAppForOsScheme = ( + osSchema: Type, + entriesSchema: Type +) => + schema.object({ + name: schema.string({ minLength: 1, maxLength: 256 }), + description: schema.maybe(schema.string({ minLength: 0, maxLength: 256, defaultValue: '' })), + effectScope: schema.oneOf([ + schema.object({ + type: schema.literal('global'), + }), + schema.object({ + type: schema.literal('policy'), + policies: schema.arrayOf(schema.string({ minLength: 1 })), // TODO: validate policies + }), + ]), + os: osSchema, + entries: schema.arrayOf(entriesSchema, { + minSize: 1, + validate(entries) { + return ( + getDuplicateFields(entries) + .map((field) => `[${entryFieldLabels[field]}] field can only be used once`) + .join(', ') || undefined + ); + }, + }), + }); export const PostTrustedAppCreateRequestSchema = { body: schema.object({ diff --git a/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts index a5c3c1eab52b3..25f5e8869ea71 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts @@ -76,10 +76,22 @@ export interface WindowsConditionEntries { entries: WindowsConditionEntry[]; } +export interface GlobalEffectScope { + type: 'global'; +} + +export interface PolicyEffectScope { + type: 'policy'; + policies: string[]; +} + +export type EffectScope = GlobalEffectScope | PolicyEffectScope; + /** Type for a new Trusted App Entry */ export type NewTrustedApp = { name: string; description?: string; + effectScope: EffectScope; } & (MacosLinuxConditionEntries | WindowsConditionEntries); /** A trusted app entry */ diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/builders.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/builders.ts index 3acb55904d298..667527d083591 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/builders.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/builders.ts @@ -28,6 +28,7 @@ export const defaultNewTrustedApp = (): NewTrustedApp => ({ os: OperatingSystem.WINDOWS, entries: [defaultConditionEntry()], description: '', + effectScope: { type: 'global' }, }); export const initialDeletionDialogState = (): TrustedAppsListPageState['deletionDialog'] => ({ diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts index faf111b1a55d8..fea7d0d524701 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts @@ -50,6 +50,7 @@ export const createSampleTrustedApp = (i: number, longTexts?: boolean): TrustedA created_by: 'someone', os: OPERATING_SYSTEMS[i % 3], entries: [], + effectScope: { type: 'global' }, }; }; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx index 24797bb483bdb..0763ca2df07b2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx @@ -239,7 +239,10 @@ describe('When showing the Trusted App Create Form', () => { expect(formProps.onChange).toHaveBeenCalledWith({ isValid: false, item: { + name: '', description: '', + os: OperatingSystem.WINDOWS, + effectScope: { type: 'global' }, entries: [ { field: ConditionEntryField.HASH, @@ -248,8 +251,6 @@ describe('When showing the Trusted App Create Form', () => { value: '', }, ], - name: '', - os: OperatingSystem.WINDOWS, }, }); }); @@ -313,7 +314,10 @@ describe('When showing the Trusted App Create Form', () => { expect(formProps.onChange).toHaveBeenLastCalledWith({ isValid: true, item: { + name: 'Some Process', description: 'some description', + os: OperatingSystem.WINDOWS, + effectScope: { type: 'global' }, entries: [ { field: ConditionEntryField.HASH, @@ -322,8 +326,6 @@ describe('When showing the Trusted App Create Form', () => { value: 'e50fb1a0e5fff590ece385082edc6c41', }, ], - name: 'Some Process', - os: OperatingSystem.WINDOWS, }, }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts index fb26ee8621bcb..ee2d23803191f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts @@ -76,6 +76,9 @@ export const PROPERTY_TITLES: Readonly< description: i18n.translate('xpack.securitySolution.trustedapps.trustedapp.description', { defaultMessage: 'Description', }), + effectScope: i18n.translate('xpack.securitySolution.trustedapps.trustedapp.effectScope', { + defaultMessage: 'Effect scope', + }), }; export const ENTRY_PROPERTY_TITLES: Readonly< diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx index d891731f6d768..3ba4c49c06507 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx @@ -45,6 +45,7 @@ describe('When on the Trusted Apps Page', () => { created_at: '2021-01-04T13:55:00.561Z', created_by: 'me', description: 'a good one', + effectScope: { type: 'global' }, entries: [ { field: ConditionEntryField.PATH, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts index 2179397c23704..ed546c2dac16e 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts @@ -14,7 +14,12 @@ import { listMock } from '../../../../../lists/server/mocks'; import { ExceptionListClient } from '../../../../../lists/server'; import { createMockConfig } from '../../../lib/detection_engine/routes/__mocks__'; -import { ConditionEntryField, OperatingSystem } from '../../../../common/endpoint/types'; +import { + ConditionEntryField, + NewTrustedApp, + OperatingSystem, + TrustedApp, +} from '../../../../common/endpoint/types'; import { EndpointAppContextService } from '../../endpoint_app_context_services'; import { createConditionEntry, createEntryMatch } from './mapping'; import { @@ -68,30 +73,32 @@ const EXCEPTION_LIST_ITEM: ExceptionListItemSchema = { name: 'linux trusted app 1', namespace_type: 'agnostic', os_types: ['linux'], - tags: [], + tags: ['policy:all'], type: 'simple', tie_breaker_id: '123', updated_at: '11/11/2011T11:11:11.111', updated_by: 'admin', }; -const NEW_TRUSTED_APP = { +const NEW_TRUSTED_APP: NewTrustedApp = { name: 'linux trusted app 1', description: 'Linux trusted app 1', os: OperatingSystem.LINUX, + effectScope: { type: 'global' }, entries: [ createConditionEntry(ConditionEntryField.PATH, '/bin/malware'), createConditionEntry(ConditionEntryField.HASH, '1234234659af249ddf3e40864e9fb241'), ], }; -const TRUSTED_APP = { +const TRUSTED_APP: TrustedApp = { id: '123', created_at: '11/11/2011T11:11:11.111', created_by: 'admin', name: 'linux trusted app 1', description: 'Linux trusted app 1', os: OperatingSystem.LINUX, + effectScope: { type: 'global' }, entries: [ createConditionEntry(ConditionEntryField.HASH, '1234234659af249ddf3e40864e9fb241'), createConditionEntry(ConditionEntryField.PATH, '/bin/malware'), diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.test.ts index b8b1e13f2052b..3b47ca7fa68f8 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.test.ts @@ -75,6 +75,7 @@ describe('mapping', () => { { name: 'linux trusted app', description: 'Linux Trusted App', + effectScope: { type: 'global' }, os: OperatingSystem.LINUX, entries: [createConditionEntry(ConditionEntryField.PATH, '/bin/malware')], }, @@ -92,6 +93,7 @@ describe('mapping', () => { { name: 'macos trusted app', description: 'MacOS Trusted App', + effectScope: { type: 'global' }, os: OperatingSystem.MAC, entries: [createConditionEntry(ConditionEntryField.PATH, '/bin/malware')], }, @@ -109,6 +111,7 @@ describe('mapping', () => { { name: 'windows trusted app', description: 'Windows Trusted App', + effectScope: { type: 'global' }, os: OperatingSystem.WINDOWS, entries: [createConditionEntry(ConditionEntryField.PATH, 'C:\\Program Files\\Malware')], }, @@ -126,6 +129,7 @@ describe('mapping', () => { { name: 'Signed trusted app', description: 'Signed Trusted App', + effectScope: { type: 'global' }, os: OperatingSystem.WINDOWS, entries: [createConditionEntry(ConditionEntryField.SIGNER, 'Microsoft Windows')], }, @@ -148,6 +152,7 @@ describe('mapping', () => { { name: 'MD5 trusted app', description: 'MD5 Trusted App', + effectScope: { type: 'global' }, os: OperatingSystem.LINUX, entries: [ createConditionEntry(ConditionEntryField.HASH, '1234234659af249ddf3e40864e9fb241'), @@ -167,6 +172,7 @@ describe('mapping', () => { { name: 'SHA1 trusted app', description: 'SHA1 Trusted App', + effectScope: { type: 'global' }, os: OperatingSystem.LINUX, entries: [ createConditionEntry( @@ -191,6 +197,7 @@ describe('mapping', () => { { name: 'SHA256 trusted app', description: 'SHA256 Trusted App', + effectScope: { type: 'global' }, os: OperatingSystem.LINUX, entries: [ createConditionEntry( @@ -218,6 +225,7 @@ describe('mapping', () => { { name: 'MD5 trusted app', description: 'MD5 Trusted App', + effectScope: { type: 'global' }, os: OperatingSystem.LINUX, entries: [ createConditionEntry(ConditionEntryField.HASH, '1234234659Af249ddf3e40864E9FB241'), @@ -253,6 +261,7 @@ describe('mapping', () => { id: '123', name: 'linux trusted app', description: 'Linux Trusted App', + effectScope: { type: 'global' }, created_at: '11/11/2011T11:11:11.111', created_by: 'admin', os: OperatingSystem.LINUX, @@ -276,6 +285,7 @@ describe('mapping', () => { id: '123', name: 'macos trusted app', description: 'MacOS Trusted App', + effectScope: { type: 'global' }, created_at: '11/11/2011T11:11:11.111', created_by: 'admin', os: OperatingSystem.MAC, @@ -297,10 +307,11 @@ describe('mapping', () => { }), { id: '123', - created_at: '11/11/2011T11:11:11.111', - created_by: 'admin', name: 'windows trusted app', description: 'Windows Trusted App', + effectScope: { type: 'global' }, + created_at: '11/11/2011T11:11:11.111', + created_by: 'admin', os: OperatingSystem.WINDOWS, entries: [createConditionEntry(ConditionEntryField.PATH, 'C:\\Program Files\\Malware')], } @@ -327,6 +338,7 @@ describe('mapping', () => { id: '123', name: 'signed trusted app', description: 'Signed trusted app', + effectScope: { type: 'global' }, created_at: '11/11/2011T11:11:11.111', created_by: 'admin', os: OperatingSystem.WINDOWS, @@ -350,6 +362,7 @@ describe('mapping', () => { id: '123', name: 'MD5 trusted app', description: 'MD5 Trusted App', + effectScope: { type: 'global' }, created_at: '11/11/2011T11:11:11.111', created_by: 'admin', os: OperatingSystem.LINUX, @@ -377,6 +390,7 @@ describe('mapping', () => { id: '123', name: 'SHA1 trusted app', description: 'SHA1 Trusted App', + effectScope: { type: 'global' }, created_at: '11/11/2011T11:11:11.111', created_by: 'admin', os: OperatingSystem.LINUX, @@ -410,6 +424,7 @@ describe('mapping', () => { id: '123', name: 'SHA256 trusted app', description: 'SHA256 Trusted App', + effectScope: { type: 'global' }, created_at: '11/11/2011T11:11:11.111', created_by: 'admin', os: OperatingSystem.LINUX, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.ts index 41b4b7b1d55fd..6b088e7635b45 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.ts @@ -20,6 +20,7 @@ import { CreateExceptionListItemOptions } from '../../../../../lists/server'; import { ConditionEntry, ConditionEntryField, + EffectScope, NewTrustedApp, OperatingSystem, TrustedApp, @@ -40,6 +41,8 @@ const OPERATING_SYSTEM_TO_OS_TYPE: Mapping = { [OperatingSystem.WINDOWS]: 'windows', }; +const POLICY_REFERENCE_PREFIX = 'policy:'; + const filterUndefined = (list: Array): T[] => { return list.filter((item: T | undefined): item is T => item !== undefined); }; @@ -51,6 +54,21 @@ export const createConditionEntry = ( return { field, value, type: 'match', operator: 'included' }; }; +export const tagsToEffectScope = (tags: string[]): EffectScope => { + const policyReferenceTags = tags.filter((tag) => tag.startsWith(POLICY_REFERENCE_PREFIX)); + + if (policyReferenceTags.some((tag) => tag === `${POLICY_REFERENCE_PREFIX}all`)) { + return { + type: 'global', + }; + } else { + return { + type: 'policy', + policies: policyReferenceTags.map((tag) => tag.substr(POLICY_REFERENCE_PREFIX.length)), + }; + } +}; + export const entriesToConditionEntriesMap = (entries: EntriesArray): ConditionEntriesMap => { return entries.reduce((result, entry) => { if (entry.field.startsWith('process.hash') && entry.type === 'match') { @@ -98,6 +116,7 @@ export const exceptionListItemToTrustedApp = ( id: exceptionListItem.id, name: exceptionListItem.name, description: exceptionListItem.description, + effectScope: tagsToEffectScope(exceptionListItem.tags), created_at: exceptionListItem.created_at, created_by: exceptionListItem.created_by, ...(os === OperatingSystem.LINUX || os === OperatingSystem.MAC @@ -147,6 +166,14 @@ export const createEntryNested = (field: string, entries: NestedEntriesArray): E return { field, entries, type: 'nested' }; }; +export const effectScopeToTags = (effectScope: EffectScope) => { + if (effectScope.type === 'policy') { + return effectScope.policies.map((policy) => `${POLICY_REFERENCE_PREFIX}${policy}`); + } else { + return [`${POLICY_REFERENCE_PREFIX}all`]; + } +}; + export const conditionEntriesToEntries = (conditionEntries: ConditionEntry[]): EntriesArray => { return conditionEntries.map((conditionEntry) => { if (conditionEntry.field === ConditionEntryField.HASH) { @@ -173,6 +200,7 @@ export const newTrustedAppToCreateExceptionListItemOptions = ({ entries, name, description = '', + effectScope, }: NewTrustedApp): CreateExceptionListItemOptions => { return { comments: [], @@ -184,7 +212,7 @@ export const newTrustedAppToCreateExceptionListItemOptions = ({ name, namespaceType: 'agnostic', osTypes: [OPERATING_SYSTEM_TO_OS_TYPE[os]], - tags: ['policy:all'], + tags: effectScopeToTags(effectScope), type: 'simple', }; }; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.test.ts index db2ca2de78b21..bce2e4e28ab28 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.test.ts @@ -8,7 +8,11 @@ import { ExceptionListItemSchema } from '../../../../../lists/common/schemas/response'; import { listMock } from '../../../../../lists/server/mocks'; import { ExceptionListClient } from '../../../../../lists/server'; -import { ConditionEntryField, OperatingSystem } from '../../../../common/endpoint/types'; +import { + ConditionEntryField, + OperatingSystem, + TrustedApp, +} from '../../../../common/endpoint/types'; import { createConditionEntry, createEntryMatch } from './mapping'; import { createTrustedApp, @@ -37,20 +41,21 @@ const EXCEPTION_LIST_ITEM: ExceptionListItemSchema = { name: 'linux trusted app 1', namespace_type: 'agnostic', os_types: ['linux'], - tags: [], + tags: ['policy:all'], type: 'simple', tie_breaker_id: '123', updated_at: '11/11/2011T11:11:11.111', updated_by: 'admin', }; -const TRUSTED_APP = { +const TRUSTED_APP: TrustedApp = { id: '123', created_at: '11/11/2011T11:11:11.111', created_by: 'admin', name: 'linux trusted app 1', description: 'Linux trusted app 1', os: OperatingSystem.LINUX, + effectScope: { type: 'global' }, entries: [ createConditionEntry(ConditionEntryField.HASH, '1234234659af249ddf3e40864e9fb241'), createConditionEntry(ConditionEntryField.PATH, '/bin/malware'), @@ -93,6 +98,7 @@ describe('service', () => { const result = await createTrustedApp(exceptionsListClient, { name: 'linux trusted app 1', description: 'Linux trusted app 1', + effectScope: { type: 'global' }, os: OperatingSystem.LINUX, entries: [ createConditionEntry(ConditionEntryField.PATH, '/bin/malware'), From d93198dace408698fabf3c369fe63cca45a9c989 Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Thu, 28 Jan 2021 10:47:59 -0500 Subject: [PATCH 04/31] [SECURITY_SOLUTION][ENDPOINT] Ability to create a Trusted App as either Global or Policy Specific (#88707) * Create form supports selecting policies or making Trusted app global * New component `EffectedPolicySelect` - for selecting policies * Enhanced `waitForAction()` test utility to provide a `validate()` option --- .../common/endpoint/types/trusted_apps.ts | 1 + .../public/common/store/test_utils.ts | 15 +- .../pages/trusted_apps/service/index.ts | 8 + .../state/trusted_apps_list_page_state.ts | 3 + .../pages/trusted_apps/state/type_guards.ts | 15 ++ .../pages/trusted_apps/store/action.ts | 6 + .../pages/trusted_apps/store/builders.ts | 1 + .../trusted_apps/store/middleware.test.ts | 1 + .../pages/trusted_apps/store/middleware.ts | 49 +++++ .../pages/trusted_apps/store/reducer.ts | 16 +- .../pages/trusted_apps/store/selectors.ts | 15 ++ .../components/create_trusted_app_flyout.tsx | 12 ++ .../create_trusted_app_form.test.tsx | 3 + .../components/create_trusted_app_form.tsx | 60 +++++- .../effected_policy_select.test.tsx | 166 +++++++++++++++ .../effected_policy_select.tsx | 193 ++++++++++++++++++ .../effected_policy_select/index.ts | 7 + .../effected_policy_select/test_utils.ts | 43 ++++ .../view/trusted_apps_page.test.tsx | 46 ++++- 19 files changed, 653 insertions(+), 7 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/effected_policy_select/effected_policy_select.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/effected_policy_select/effected_policy_select.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/effected_policy_select/index.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/effected_policy_select/test_utils.ts diff --git a/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts index 25f5e8869ea71..e60ab6de14269 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts @@ -82,6 +82,7 @@ export interface GlobalEffectScope { export interface PolicyEffectScope { type: 'policy'; + /** An array of Endpoint Integration Policy UUIDs */ policies: string[]; } diff --git a/x-pack/plugins/security_solution/public/common/store/test_utils.ts b/x-pack/plugins/security_solution/public/common/store/test_utils.ts index c1d54192c86b1..7616dfccddaff 100644 --- a/x-pack/plugins/security_solution/public/common/store/test_utils.ts +++ b/x-pack/plugins/security_solution/public/common/store/test_utils.ts @@ -9,6 +9,10 @@ import { Dispatch } from 'redux'; import { State, ImmutableMiddlewareFactory } from './types'; import { AppAction } from './actions'; +interface WaitForActionOptions { + validate?: (action: A extends { type: T } ? A : never) => boolean; +} + /** * Utilities for testing Redux middleware */ @@ -21,7 +25,10 @@ export interface MiddlewareActionSpyHelper(actionType: T) => Promise; + waitForAction: ( + actionType: T, + options?: WaitForActionOptions + ) => Promise; /** * A property holding the information around the calls that were processed by the internal * `actionSpyMiddelware`. This property holds the information typically found in Jets's mocked @@ -78,7 +85,7 @@ export const createSpyMiddleware = < let spyDispatch: jest.Mock>; return { - waitForAction: async (actionType) => { + waitForAction: async (actionType, options = {}) => { type ResolvedAction = A extends { type: typeof actionType } ? A : never; // Error is defined here so that we get a better stack trace that points to the test from where it was used @@ -87,6 +94,10 @@ export const createSpyMiddleware = < return new Promise((resolve, reject) => { const watch: ActionWatcher = (action) => { if (action.type === actionType) { + if (options.validate && !options.validate(action as ResolvedAction)) { + return; + } + watchers.delete(watch); clearTimeout(timeout); resolve(action as ResolvedAction); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts index 578043f4321e9..4ad3a5f585cb5 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts @@ -24,11 +24,15 @@ import { } from '../../../../../common/endpoint/types/trusted_apps'; import { resolvePathVariables } from './utils'; +import { sendGetEndpointSpecificPackagePolicies } from '../../policy/store/services/ingest'; export interface TrustedAppsService { getTrustedAppsList(request: GetTrustedAppsListRequest): Promise; deleteTrustedApp(request: DeleteTrustedAppsRequestParams): Promise; createTrustedApp(request: PostTrustedAppCreateRequest): Promise; + getPolicyList( + options?: Parameters[1] + ): ReturnType; } export class TrustedAppsHttpService implements TrustedAppsService { @@ -53,4 +57,8 @@ export class TrustedAppsHttpService implements TrustedAppsService { async getTrustedAppsSummary() { return this.http.get(TRUSTED_APPS_SUMMARY_API); } + + getPolicyList(options?: Parameters[1]) { + return sendGetEndpointSpecificPackagePolicies(this.http, options); + } } diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/trusted_apps_list_page_state.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/trusted_apps_list_page_state.ts index ea934881f6220..6899294802de8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/trusted_apps_list_page_state.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/trusted_apps_list_page_state.ts @@ -7,6 +7,7 @@ import { NewTrustedApp, TrustedApp } from '../../../../../common/endpoint/types/trusted_apps'; import { AsyncResourceState } from '.'; +import { GetPolicyListResponse } from '../../policy/types'; export interface Pagination { pageIndex: number; @@ -54,6 +55,8 @@ export interface TrustedAppsListPageState { confirmed: boolean; submissionResourceState: AsyncResourceState; }; + /** A list of all available polices for use in associating TA to policies */ + policies: AsyncResourceState; location: TrustedAppsListPageLocation; active: boolean; } diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/type_guards.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/type_guards.ts index 66f4eff81dbdd..9a7d5fdd7b8d5 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/type_guards.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/type_guards.ts @@ -8,7 +8,10 @@ import { ConditionEntry, ConditionEntryField, + EffectScope, + GlobalEffectScope, MacosLinuxConditionEntry, + PolicyEffectScope, WindowsConditionEntry, } from '../../../../../common/endpoint/types'; @@ -23,3 +26,15 @@ export const isMacosLinuxTrustedAppCondition = ( ): condition is MacosLinuxConditionEntry => { return condition.field !== ConditionEntryField.SIGNER; }; + +export const isGlobalEffectScope = ( + effectedScope: EffectScope +): effectedScope is GlobalEffectScope => { + return effectedScope.type === 'global'; +}; + +export const isPolicyEffectScope = ( + effectedScope: EffectScope +): effectedScope is PolicyEffectScope => { + return effectedScope.type === 'policy'; +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/action.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/action.ts index aaa05f550b208..8c28323ab8987 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/action.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/action.ts @@ -9,6 +9,7 @@ import { Action } from 'redux'; import { NewTrustedApp, TrustedApp } from '../../../../../common/endpoint/types'; import { AsyncResourceState, TrustedAppsListData } from '../state'; +import { GetPolicyListResponse } from '../../policy/types'; export type TrustedAppsListDataOutdated = Action<'trustedAppsListDataOutdated'>; @@ -59,6 +60,10 @@ export type TrustedAppsExistResponse = Action<'trustedAppsExistStateChanged'> & payload: AsyncResourceState; }; +export type TrustedAppsPoliciesStateChanged = Action<'trustedAppsPoliciesStateChanged'> & { + payload: AsyncResourceState; +}; + export type TrustedAppsPageAction = | TrustedAppsListDataOutdated | TrustedAppsListResourceStateChanged @@ -71,4 +76,5 @@ export type TrustedAppsPageAction = | TrustedAppCreationDialogFormStateUpdated | TrustedAppCreationDialogConfirmed | TrustedAppsExistResponse + | TrustedAppsPoliciesStateChanged | TrustedAppCreationDialogClosed; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/builders.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/builders.ts index 667527d083591..a104007874367 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/builders.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/builders.ts @@ -49,6 +49,7 @@ export const initialTrustedAppsPageState = (): TrustedAppsListPageState => ({ }, deletionDialog: initialDeletionDialogState(), creationDialog: initialCreationDialogState(), + policies: { type: 'UninitialisedResourceState' }, location: { page_index: MANAGEMENT_DEFAULT_PAGE, page_size: MANAGEMENT_DEFAULT_PAGE_SIZE, diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts index 064b108848d2f..ff456b05d7dcd 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts @@ -49,6 +49,7 @@ const createTrustedAppsServiceMock = (): jest.Mocked => ({ getTrustedAppsList: jest.fn(), deleteTrustedApp: jest.fn(), createTrustedApp: jest.fn(), + getPolicyList: jest.fn(), }); const createStoreSetup = (trustedAppsService: TrustedAppsService) => { diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts index 3e83b213f0f7e..17c84ce35a7a6 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts @@ -54,6 +54,7 @@ import { getListTotalItemsCount, trustedAppsListPageActive, entriesExistState, + policiesState, } from './selectors'; const createTrustedAppsListResourceStateChangedAction = ( @@ -268,6 +269,53 @@ const checkTrustedAppsExistIfNeeded = async ( } }; +export const retrieveListOfPoliciesIfNeeded = async ( + { getState, dispatch }: ImmutableMiddlewareAPI, + trustedAppsService: TrustedAppsService +) => { + const currentState = getState(); + const currentPoliciesState = policiesState(currentState); + const isLoading = isLoadingResourceState(currentPoliciesState); + const isPageActive = trustedAppsListPageActive(currentState); + const isCreateFlow = isCreationDialogLocation(currentState); + + if (isPageActive && isCreateFlow && !isLoading) { + dispatch({ + type: 'trustedAppsPoliciesStateChanged', + payload: { + type: 'LoadingResourceState', + previousState: currentPoliciesState, + } as TrustedAppsListPageState['policies'], + }); + + try { + const policyList = await trustedAppsService.getPolicyList({ + query: { + page: 1, + perPage: 1000, + }, + }); + + dispatch({ + type: 'trustedAppsPoliciesStateChanged', + payload: { + type: 'LoadedResourceState', + data: policyList, + }, + }); + } catch (error) { + dispatch({ + type: 'trustedAppsPoliciesStateChanged', + payload: { + type: 'FailedResourceState', + error: error.body, + lastLoadedState: getLastLoadedResourceState(policiesState(getState())), + }, + }); + } + } +}; + export const createTrustedAppsPageMiddleware = ( trustedAppsService: TrustedAppsService ): ImmutableMiddleware => { @@ -282,6 +330,7 @@ export const createTrustedAppsPageMiddleware = ( if (action.type === 'userChangedUrl') { updateCreationDialogIfNeeded(store); + retrieveListOfPoliciesIfNeeded(store, trustedAppsService); } if (action.type === 'trustedAppCreationDialogConfirmed') { diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts index aff5cacf081c6..2e55bd2a846db 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts @@ -29,6 +29,7 @@ import { TrustedAppCreationDialogConfirmed, TrustedAppCreationDialogClosed, TrustedAppsExistResponse, + TrustedAppsPoliciesStateChanged, } from './action'; import { TrustedAppsListPageState } from '../state'; @@ -37,7 +38,7 @@ import { initialDeletionDialogState, initialTrustedAppsPageState, } from './builders'; -import { entriesExistState } from './selectors'; +import { entriesExistState, trustedAppsListPageActive } from './selectors'; type StateReducer = ImmutableReducer; type CaseReducer = ( @@ -155,6 +156,16 @@ const updateEntriesExists: CaseReducer = (state, { pay return state; }; +const updatePolicies: CaseReducer = (state, { payload }) => { + if (trustedAppsListPageActive(state)) { + return { + ...state, + policies: payload, + }; + } + return state; +}; + export const trustedAppsPageReducer: StateReducer = ( state = initialTrustedAppsPageState(), action @@ -198,6 +209,9 @@ export const trustedAppsPageReducer: StateReducer = ( case 'trustedAppsExistStateChanged': return updateEntriesExists(state, action); + + case 'trustedAppsPoliciesStateChanged': + return updatePolicies(state, action); } return state; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts index baa68eb314140..04ef36ffb2c68 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts @@ -24,6 +24,7 @@ import { TrustedAppsListPageLocation, TrustedAppsListPageState, } from '../state'; +import { GetPolicyListResponse } from '../../policy/types'; export const needsRefreshOfListData = (state: Immutable): boolean => { const freshDataTimestamp = state.listView.freshDataTimestamp; @@ -185,3 +186,17 @@ export const entriesExist: (state: Immutable) => boole export const trustedAppsListPageActive: (state: Immutable) => boolean = ( state ) => state.active; + +export const policiesState = ( + state: Immutable +): Immutable => state.policies; + +export const loadingPolicies: ( + state: Immutable +) => boolean = createSelector(policiesState, (policies) => isLoadingResourceState(policies)); + +export const listOfPolicies: ( + state: Immutable +) => Immutable = createSelector(policiesState, (policies) => { + return isLoadedResourceState(policies) ? policies.data.items : []; +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_flyout.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_flyout.tsx index 1c87bf4304640..2b3390ff36bc3 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_flyout.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_flyout.tsx @@ -28,6 +28,8 @@ import { isCreationDialogFormValid, isCreationInProgress, isCreationSuccessful, + listOfPolicies, + loadingPolicies, } from '../../store/selectors'; import { AppAction } from '../../../../../common/store/actions'; import { useTrustedAppsSelector } from '../hooks'; @@ -42,6 +44,8 @@ export const CreateTrustedAppFlyout = memo( const creationErrors = useTrustedAppsSelector(getCreationError); const creationSuccessful = useTrustedAppsSelector(isCreationSuccessful); const isFormValid = useTrustedAppsSelector(isCreationDialogFormValid); + const isLoadingPolicies = useTrustedAppsSelector(loadingPolicies); + const policyList = useTrustedAppsSelector(listOfPolicies); const dataTestSubj = flyoutProps['data-test-subj']; @@ -53,6 +57,13 @@ export const CreateTrustedAppFlyout = memo( : undefined, [creationErrors] ); + const policies = useMemo(() => { + return { + // Casting is needed due to the use of `Immutable<>` on the return value from the selector above + options: policyList as CreateTrustedAppFormProps['policies']['options'], + isLoading: isLoadingPolicies, + }; + }, [isLoadingPolicies, policyList]); const getTestId = useCallback( (suffix: string): string | undefined => { @@ -112,6 +123,7 @@ export const CreateTrustedAppFlyout = memo( onChange={handleFormOnChange} isInvalid={!!creationErrors} error={creationErrorsMessage} + policies={policies} data-test-subj={getTestId('createForm')} /> diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx index 0763ca2df07b2..3a506d169f8c2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx @@ -99,6 +99,9 @@ describe('When showing the Trusted App Create Form', () => { formProps = { 'data-test-subj': dataTestSubjForForm, onChange: jest.fn(), + policies: { + options: [], + }, }; render = () => mockedContext.render(); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx index f99c3567e7912..fef0dca64f826 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx @@ -18,6 +18,7 @@ import { i18n } from '@kbn/i18n'; import { EuiFormProps } from '@elastic/eui/src/components/form/form'; import { ConditionEntryField, + EffectScope, MacosLinuxConditionEntry, NewTrustedApp, OperatingSystem, @@ -25,12 +26,18 @@ import { import { isValidHash } from '../../../../../../common/endpoint/validation/trusted_apps'; import { + isGlobalEffectScope, isMacosLinuxTrustedAppCondition, isWindowsTrustedAppCondition, } from '../../state/type_guards'; import { defaultConditionEntry, defaultNewTrustedApp } from '../../store/builders'; import { OS_TITLES } from '../translations'; import { LogicalConditionBuilder, LogicalConditionBuilderProps } from './logical_condition'; +import { + EffectedPolicySelect, + EffectedPolicySelection, + EffectedPolicySelectProps, +} from './effected_policy_select'; const OPERATING_SYSTEMS: readonly OperatingSystem[] = [ OperatingSystem.MAC, @@ -159,12 +166,17 @@ export type CreateTrustedAppFormProps = Pick< EuiFormProps, 'className' | 'data-test-subj' | 'isInvalid' | 'error' | 'invalidCallout' > & { + onChange: (state: TrustedAppFormState) => void; /** if form should be shown full width of parent container */ fullWidth?: boolean; - onChange: (state: TrustedAppFormState) => void; + /** Setting passed on to the EffectedPolicySelect component */ + policies: { + options: EffectedPolicySelectProps['options']; + isLoading?: EffectedPolicySelectProps['isLoading']; + }; }; export const CreateTrustedAppForm = memo( - ({ fullWidth, onChange, ...formProps }) => { + ({ fullWidth, onChange, policies = { options: [] }, ...formProps }) => { const dataTestSubj = formProps['data-test-subj']; const osOptions: Array> = useMemo( @@ -174,6 +186,13 @@ export const CreateTrustedAppForm = memo( const [formValues, setFormValues] = useState(defaultNewTrustedApp()); + // We create local state for the list of policies because we want the selected policies to + // persist while the user is on the form and possibly toggling between global/non-global + const [selectedPolicies, setSelectedPolicies] = useState({ + isGlobal: isGlobalEffectScope(formValues.effectScope), + selected: [], + }); + const [validationResult, setValidationResult] = useState(() => validateFormValues(formValues) ); @@ -325,6 +344,33 @@ export const CreateTrustedAppForm = memo( }); }, []); + const handlePolicySelectChange: EffectedPolicySelectProps['onChange'] = useCallback( + (selection) => { + setSelectedPolicies(() => selection); + + let newEffectedScope: EffectScope; + + if (selection.isGlobal) { + newEffectedScope = { + type: 'global', + }; + } else { + newEffectedScope = { + type: 'policy', + policies: selection.selected.map((policy) => policy.id), + }; + } + + setFormValues((prevState) => { + return { + ...prevState, + effectScope: newEffectedScope, + }; + }); + }, + [] + ); + // Anytime the form values change, re-validate useEffect(() => { setValidationResult(validateFormValues(formValues)); @@ -410,6 +456,16 @@ export const CreateTrustedAppForm = memo( data-test-subj={getTestId('descriptionField')} /> + + + ); } diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/effected_policy_select/effected_policy_select.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/effected_policy_select/effected_policy_select.test.tsx new file mode 100644 index 0000000000000..3d45faf0e2546 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/effected_policy_select/effected_policy_select.test.tsx @@ -0,0 +1,166 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EndpointDocGenerator } from '../../../../../../../common/endpoint/generate_data'; +import { EffectedPolicySelect, EffectedPolicySelectProps } from './effected_policy_select'; +import { + AppContextTestRender, + createAppRootMockRenderer, +} from '../../../../../../common/mock/endpoint'; +import React from 'react'; +import { forceHTMLElementOffsetWith } from './test_utils'; +import { fireEvent, act } from '@testing-library/react'; + +describe('when using EffectedPolicySelect component', () => { + const generator = new EndpointDocGenerator('effected-policy-select'); + + let mockedContext: AppContextTestRender; + let componentProps: EffectedPolicySelectProps; + let renderResult: ReturnType; + + const handleOnChange: jest.MockedFunction = jest.fn(); + const render = (props: Partial = {}) => { + componentProps = { + ...componentProps, + ...props, + }; + renderResult = mockedContext.render(); + return renderResult; + }; + let resetHTMLElementOffsetWidth: () => void; + + beforeAll(() => { + resetHTMLElementOffsetWidth = forceHTMLElementOffsetWith(); + }); + + afterAll(() => resetHTMLElementOffsetWidth()); + + beforeEach(() => { + // Default props + componentProps = { + options: [], + isGlobal: true, + onChange: handleOnChange, + 'data-test-subj': 'test', + }; + mockedContext = createAppRootMockRenderer(); + }); + + afterEach(() => { + handleOnChange.mockClear(); + }); + + describe('and no policy entries exist', () => { + it('should display no options available message', () => { + const { getByTestId } = render(); + expect(getByTestId('test-policiesSelectable').textContent).toEqual('No options available'); + }); + }); + + describe('and policy entries exist', () => { + const policyId = 'abc123'; + const policyTestSubj = `policy-${policyId}`; + + const toggleGlobalSwitch = () => { + act(() => { + fireEvent.click(renderResult.getByTestId('test-globalSwitch')); + }); + }; + + const clickOnPolicy = () => { + act(() => { + fireEvent.click(renderResult.getByTestId(policyTestSubj)); + }); + }; + + beforeEach(() => { + const policy = generator.generatePolicyPackagePolicy(); + policy.name = 'test policy A'; + policy.id = policyId; + + componentProps = { + ...componentProps, + options: [policy], + }; + + handleOnChange.mockImplementation((selection) => { + componentProps = { + ...componentProps, + ...selection, + }; + renderResult.rerender(); + }); + }); + + it('should display policies', () => { + const { getByTestId } = render(); + expect(getByTestId(policyTestSubj)); + }); + + it('should disable policy items if global is checked', () => { + const { getByTestId } = render(); + expect(getByTestId(policyTestSubj).getAttribute('aria-disabled')).toEqual('true'); + }); + + it('should enable policy items if global is unchecked', async () => { + const { getByTestId } = render(); + toggleGlobalSwitch(); + expect(getByTestId(policyTestSubj).getAttribute('aria-disabled')).toEqual('false'); + }); + + it('should call onChange with selection when global is toggled', () => { + render(); + + toggleGlobalSwitch(); + expect(handleOnChange.mock.calls[0][0]).toEqual({ + isGlobal: false, + selected: [], + }); + + toggleGlobalSwitch(); + expect(handleOnChange.mock.calls[1][0]).toEqual({ + isGlobal: true, + selected: [], + }); + }); + + it('should not allow clicking on policies when global is true', () => { + render(); + + clickOnPolicy(); + expect(handleOnChange.mock.calls.length).toBe(0); + + // Select a Policy, then switch back to global and try to click the policy again (should be disabled and trigger onChange()) + toggleGlobalSwitch(); + clickOnPolicy(); + toggleGlobalSwitch(); + clickOnPolicy(); + expect(handleOnChange.mock.calls.length).toBe(3); + expect(handleOnChange.mock.calls[2][0]).toEqual({ + isGlobal: true, + selected: [componentProps.options[0]], + }); + }); + + it('should maintain policies selection even if global was checked', () => { + render(); + + toggleGlobalSwitch(); + clickOnPolicy(); + expect(handleOnChange.mock.calls[1][0]).toEqual({ + isGlobal: false, + selected: [componentProps.options[0]], + }); + + // Toggle isGlobal back to True + toggleGlobalSwitch(); + expect(handleOnChange.mock.calls[2][0]).toEqual({ + isGlobal: true, + selected: [componentProps.options[0]], + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/effected_policy_select/effected_policy_select.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/effected_policy_select/effected_policy_select.tsx new file mode 100644 index 0000000000000..e6141665a5dc6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/effected_policy_select/effected_policy_select.tsx @@ -0,0 +1,193 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { + EuiCheckbox, + EuiFormRow, + EuiSelectable, + EuiSelectableProps, + EuiSwitch, + EuiSwitchProps, + htmlIdGenerator, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { EuiSelectableOption } from '@elastic/eui/src/components/selectable/selectable_option'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { PolicyData } from '../../../../../../../common/endpoint/types'; +import { MANAGEMENT_APP_ID } from '../../../../../common/constants'; +import { getPolicyDetailPath } from '../../../../../common/routing'; +import { useFormatUrl } from '../../../../../../common/components/link_to'; +import { SecurityPageName } from '../../../../../../../common/constants'; +import { LinkToApp } from '../../../../../../common/components/endpoint/link_to_app'; + +const NOOP = () => {}; +const DEFAULT_LIST_PROPS: EuiSelectableProps['listProps'] = { bordered: true, showIcons: false }; + +interface OptionPolicyData { + policy: PolicyData; +} + +type EffectedPolicyOption = EuiSelectableOption; + +export interface EffectedPolicySelection { + isGlobal: boolean; + selected: PolicyData[]; +} + +export type EffectedPolicySelectProps = Omit< + EuiSelectableProps, + 'onChange' | 'options' | 'children' | 'searchable' +> & { + options: PolicyData[]; + isGlobal: boolean; + onChange: (selection: EffectedPolicySelection) => void; + selected?: PolicyData[]; +}; +export const EffectedPolicySelect = memo( + ({ + isGlobal, + onChange, + listProps, + options, + selected = [], + 'data-test-subj': dataTestSubj, + ...otherSelectableProps + }) => { + const { formatUrl } = useFormatUrl(SecurityPageName.administration); + + const [, setIsFirstRender] = useState(true); + const [selectionState, setSelectionState] = useState({ + isGlobal, + selected, + }); + + const getTestId = useCallback( + (suffix): string | undefined => { + if (dataTestSubj) { + return `${dataTestSubj}-${suffix}`; + } + }, + [dataTestSubj] + ); + + const selectableOptions: EffectedPolicyOption[] = useMemo(() => { + const isPolicySelected = new Set(selected.map((policy) => policy.id)); + + return options + .map((policy) => ({ + label: policy.name, + prepend: ( + + ), + append: ( + + + + ), + policy, + checked: isPolicySelected.has(policy.id) ? 'on' : undefined, + disabled: isGlobal, + 'data-test-subj': `policy-${policy.id}`, + })) + .sort(({ label: labelA }, { label: labelB }) => labelA.localeCompare(labelB)); + }, [formatUrl, isGlobal, options, selected]); + + const handleOnPolicySelectChange = useCallback< + Required>['onChange'] + >((currentOptions) => { + setSelectionState((prevState) => ({ + ...prevState, + selected: currentOptions.filter((opt) => opt.checked).map((opt) => opt.policy), + })); + }, [])!; + + const handleGlobalSwitchChange: EuiSwitchProps['onChange'] = useCallback( + ({ target: { checked } }) => { + setSelectionState((prevState) => ({ ...prevState, isGlobal: checked })); + }, + [] + ); + + const listBuilderCallback: EuiSelectableProps['children'] = useCallback((list, search) => { + return ( + <> + {search} + {list} + + ); + }, []); + + // Anytime selection state is updated, call `onChange`, but not on first render + useEffect(() => { + setIsFirstRender((isFirstRender) => { + if (isFirstRender) { + return false; + } + onChange(selectionState); + return false; + }); + }, [onChange, selectionState]); + + return ( + <> + + + + + + {...otherSelectableProps} + options={selectableOptions} + listProps={listProps || DEFAULT_LIST_PROPS} + onChange={handleOnPolicySelectChange} + searchable={true} + data-test-subj={getTestId('policiesSelectable')} + > + {listBuilderCallback} + + + + ); + } +); + +EffectedPolicySelect.displayName = 'EffectedPolicySelect'; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/effected_policy_select/index.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/effected_policy_select/index.ts new file mode 100644 index 0000000000000..b4d653a98e106 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/effected_policy_select/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './effected_policy_select'; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/effected_policy_select/test_utils.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/effected_policy_select/test_utils.ts new file mode 100644 index 0000000000000..d5af36618d208 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/effected_policy_select/test_utils.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * Forces the `offsetWidth` of `HTMLElement` to a given value. Needed due to the use of + * `react-virtualized-auto-sizer` by the eui `Selectable` component + * + * @param [width=100] + * @returns reset(): void + * + * @example + * const resetEnv = forceHTMLElementOffsetWidth(); + * //... later + * resetEnv(); + */ +export const forceHTMLElementOffsetWith = (width: number = 100): (() => void) => { + const currentOffsetDefinition = Object.getOwnPropertyDescriptor( + window.HTMLElement.prototype, + 'offsetWidth' + ); + + Object.defineProperties(window.HTMLElement.prototype, { + offsetWidth: { + ...(currentOffsetDefinition || {}), + get() { + return width; + }, + }, + }); + + return function reset() { + if (currentOffsetDefinition) { + Object.defineProperties(window.HTMLElement.prototype, { + offsetWidth: { + ...(currentOffsetDefinition || {}), + }, + }); + } + }; +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx index 3ba4c49c06507..ef20ab1df45b7 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx @@ -21,6 +21,13 @@ import { } from '../../../../../common/endpoint/types'; import { HttpFetchOptions } from 'kibana/public'; import { TRUSTED_APPS_LIST_API } from '../../../../../common/endpoint/constants'; +import { + GetPackagePoliciesResponse, + PACKAGE_POLICY_API_ROUTES, +} from '../../../../../../fleet/common'; +import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_data'; +import { isLoadedResourceState } from '../state'; +import { forceHTMLElementOffsetWith } from './components/effected_policy_select/test_utils'; jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ htmlIdGenerator: () => () => 'mockId', @@ -30,6 +37,8 @@ describe('When on the Trusted Apps Page', () => { const expectedAboutInfo = 'Add a trusted application to improve performance or alleviate conflicts with other applications running on your hosts. Trusted applications will be applied to hosts running Endpoint Security.'; + const generator = new EndpointDocGenerator('policy-list'); + let mockedContext: AppContextTestRender; let history: AppContextTestRender['history']; let coreStart: AppContextTestRender['coreStart']; @@ -72,6 +81,21 @@ describe('When on the Trusted Apps Page', () => { per_page: httpOptions?.query?.per_page ?? 20, }; } + + if (path === PACKAGE_POLICY_API_ROUTES.LIST_PATTERN) { + const policy = generator.generatePolicyPackagePolicy(); + policy.name = 'test policy A'; + policy.id = 'abc123'; + + const response: GetPackagePoliciesResponse = { + items: [policy], + page: 1, + perPage: 1000, + total: 1, + }; + return response; + } + if (currentGetHandler) { return currentGetHandler(...args); } @@ -130,10 +154,21 @@ describe('When on the Trusted Apps Page', () => { await act(async () => { await waitForAction('trustedAppsListResourceStateChanged'); }); - const addButton = renderResult.getByTestId('trustedAppsListAddButton'); - reactTestingLibrary.act(() => { + + act(() => { + const addButton = renderResult.getByTestId('trustedAppsListAddButton'); fireEvent.click(addButton, { button: 1 }); }); + + // Wait for the policies to be loaded + await act(async () => { + await waitForAction('trustedAppsPoliciesStateChanged', { + validate: (action) => { + return isLoadedResourceState(action.payload); + }, + }); + }); + return renderResult; }; @@ -166,6 +201,13 @@ describe('When on the Trusted Apps Page', () => { expect(queryByTestId('addTrustedAppFlyout-createForm')).not.toBeNull(); }); + it('should have list of policies populated', async () => { + const resetEnv = forceHTMLElementOffsetWith(); + const { getByTestId } = await renderAndClickAddButton(); + expect(getByTestId('policy-abc123')); + resetEnv(); + }); + it('should initially have the flyout Add button disabled', async () => { const { getByTestId } = await renderAndClickAddButton(); expect((getByTestId('addTrustedAppFlyout-createButton') as HTMLButtonElement).disabled).toBe( From 00932d40679923fa794c4a677689c6e86d6f8921 Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Fri, 5 Feb 2021 10:00:23 -0500 Subject: [PATCH 05/31] [SECURITY SOLUTION][ENDPOINT] UI for editing Trusted Application items (#89479) * Add Edit button to TA card UI * Support additional url params (`show`, `id`) * Refactor TrustedAppForm to support Editing of an existing entry --- .../common/endpoint/types/index.ts | 5 + .../public/management/common/routing.ts | 20 +- .../service/to_new_trusted_app.ts | 29 + .../state/trusted_apps_list_page_state.ts | 6 +- .../pages/trusted_apps/state/type_guards.ts | 5 +- .../pages/trusted_apps/store/action.ts | 5 + .../pages/trusted_apps/store/builders.ts | 1 + .../trusted_apps/store/middleware.test.ts | 33 +- .../pages/trusted_apps/store/middleware.ts | 80 ++- .../pages/trusted_apps/store/reducer.test.ts | 8 +- .../pages/trusted_apps/store/reducer.ts | 16 +- .../pages/trusted_apps/store/selectors.ts | 35 +- .../components/create_trusted_app_flyout.tsx | 50 +- .../create_trusted_app_form.test.tsx | 293 ++++---- .../components/create_trusted_app_form.tsx | 292 ++++---- .../effected_policy_select.test.tsx | 9 +- .../effected_policy_select.tsx | 48 +- .../effected_policy_select/index.ts | 5 +- .../effected_policy_select/test_utils.ts | 7 +- .../__snapshots__/index.test.tsx.snap | 18 + .../trusted_app_card/index.stories.tsx | 24 +- .../trusted_app_card/index.test.tsx | 12 +- .../components/trusted_app_card/index.tsx | 17 +- .../__snapshots__/index.test.tsx.snap | 638 +++++++++++++++++- .../components/trusted_apps_grid/index.tsx | 27 +- .../__snapshots__/index.test.tsx.snap | 21 + .../components/trusted_apps_list/index.tsx | 58 +- .../pages/trusted_apps/view/translations.ts | 7 + .../view/trusted_apps_page.test.tsx | 59 +- .../trusted_apps/view/trusted_apps_page.tsx | 12 +- .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - 32 files changed, 1485 insertions(+), 359 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/to_new_trusted_app.ts 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 87268f02a16e1..0b41dc5608fe9 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -62,6 +62,11 @@ type ImmutableMap = ReadonlyMap, Immutable>; type ImmutableSet = ReadonlySet>; type ImmutableObject = { readonly [K in keyof T]: Immutable }; +/** + * Utility type that will return back a union of the given [T]ype and an Immutable version of it + */ +export type MaybeImmutable = T | Immutable; + /** * Stats for related events for a particular node in a resolver graph. */ diff --git a/x-pack/plugins/security_solution/public/management/common/routing.ts b/x-pack/plugins/security_solution/public/management/common/routing.ts index cbcc054e7c6a9..bf754720f314b 100644 --- a/x-pack/plugins/security_solution/public/management/common/routing.ts +++ b/x-pack/plugins/security_solution/public/management/common/routing.ts @@ -108,6 +108,7 @@ const normalizeTrustedAppsPageLocation = ( : {}), ...(!isDefaultOrMissing(location.view_type, 'grid') ? { view_type: location.view_type } : {}), ...(!isDefaultOrMissing(location.show, undefined) ? { show: location.show } : {}), + ...(!isDefaultOrMissing(location.id, undefined) ? { id: location.id } : {}), }; } else { return {}; @@ -147,11 +148,20 @@ export const extractListPaginationParams = (query: querystring.ParsedUrlQuery) = export const extractTrustedAppsListPageLocation = ( query: querystring.ParsedUrlQuery -): TrustedAppsListPageLocation => ({ - ...extractListPaginationParams(query), - view_type: extractFirstParamValue(query, 'view_type') === 'list' ? 'list' : 'grid', - show: extractFirstParamValue(query, 'show') === 'create' ? 'create' : undefined, -}); +): TrustedAppsListPageLocation => { + const showParamValue = extractFirstParamValue( + query, + 'show' + ) as TrustedAppsListPageLocation['show']; + + return { + ...extractListPaginationParams(query), + view_type: extractFirstParamValue(query, 'view_type') === 'list' ? 'list' : 'grid', + show: + showParamValue && ['edit', 'create'].includes(showParamValue) ? showParamValue : undefined, + id: extractFirstParamValue(query, 'id'), + }; +}; export const getTrustedAppsListPath = (location?: Partial): string => { const path = generatePath(MANAGEMENT_ROUTING_TRUSTED_APPS_PATH, { diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/to_new_trusted_app.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/to_new_trusted_app.ts new file mode 100644 index 0000000000000..7b0bd3f995b47 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/to_new_trusted_app.ts @@ -0,0 +1,29 @@ +/* + * 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 { MaybeImmutable, NewTrustedApp } from '../../../../../common/endpoint/types'; +import { defaultNewTrustedApp } from '../store/builders'; + +const NEW_TRUSTED_APP_KEYS: Array = [ + 'name', + 'effectScope', + 'entries', + 'description', + 'os', +]; + +export const toNewTrustedApp = ( + trustedApp: MaybeImmutable +): NewTrustedApp => { + const newTrustedApp = defaultNewTrustedApp(); + for (const key of NEW_TRUSTED_APP_KEYS) { + // This should be safe. Its needed due to the inter-dependency on property values (`os` <=> `entries`) + // @ts-expect-error + newTrustedApp[key] = trustedApp[key]; + } + return newTrustedApp; +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/trusted_apps_list_page_state.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/trusted_apps_list_page_state.ts index 6899294802de8..1c1fca4b55abc 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/trusted_apps_list_page_state.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/trusted_apps_list_page_state.ts @@ -30,7 +30,9 @@ export interface TrustedAppsListPageLocation { page_index: number; page_size: number; view_type: ViewType; - show?: 'create'; + show?: 'create' | 'edit'; + /** Used for editing. The ID of the selected trusted app */ + id?: string; } export interface TrustedAppsListPageState { @@ -52,6 +54,8 @@ export interface TrustedAppsListPageState { entry: NewTrustedApp; isValid: boolean; }; + /** The trusted app to be edited (when in edit mode) */ + editItem?: AsyncResourceState; confirmed: boolean; submissionResourceState: AsyncResourceState; }; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/type_guards.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/type_guards.ts index 9a7d5fdd7b8d5..3f9e9d53f69e4 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/type_guards.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/type_guards.ts @@ -11,6 +11,7 @@ import { EffectScope, GlobalEffectScope, MacosLinuxConditionEntry, + MaybeImmutable, PolicyEffectScope, WindowsConditionEntry, } from '../../../../../common/endpoint/types'; @@ -28,13 +29,13 @@ export const isMacosLinuxTrustedAppCondition = ( }; export const isGlobalEffectScope = ( - effectedScope: EffectScope + effectedScope: MaybeImmutable ): effectedScope is GlobalEffectScope => { return effectedScope.type === 'global'; }; export const isPolicyEffectScope = ( - effectedScope: EffectScope + effectedScope: MaybeImmutable ): effectedScope is PolicyEffectScope => { return effectedScope.type === 'policy'; }; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/action.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/action.ts index 8c28323ab8987..34f48142c7032 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/action.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/action.ts @@ -52,6 +52,10 @@ export type TrustedAppCreationDialogFormStateUpdated = Action<'trustedAppCreatio }; }; +export type TrustedAppCreationEditItemStateChanged = Action<'trustedAppCreationEditItemStateChanged'> & { + payload: AsyncResourceState; +}; + export type TrustedAppCreationDialogConfirmed = Action<'trustedAppCreationDialogConfirmed'>; export type TrustedAppCreationDialogClosed = Action<'trustedAppCreationDialogClosed'>; @@ -72,6 +76,7 @@ export type TrustedAppsPageAction = | TrustedAppDeletionDialogConfirmed | TrustedAppDeletionDialogClosed | TrustedAppCreationSubmissionResourceStateChanged + | TrustedAppCreationEditItemStateChanged | TrustedAppCreationDialogStarted | TrustedAppCreationDialogFormStateUpdated | TrustedAppCreationDialogConfirmed diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/builders.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/builders.ts index a104007874367..ece2c9e29750f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/builders.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/builders.ts @@ -54,6 +54,7 @@ export const initialTrustedAppsPageState = (): TrustedAppsListPageState => ({ page_index: MANAGEMENT_DEFAULT_PAGE, page_size: MANAGEMENT_DEFAULT_PAGE_SIZE, show: undefined, + id: undefined, view_type: 'grid', }, active: false, diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts index ff456b05d7dcd..f700a88ecbf25 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts @@ -21,10 +21,11 @@ import { } from '../test_utils'; import { TrustedAppsService } from '../service'; -import { Pagination, TrustedAppsListPageState } from '../state'; +import { Pagination, TrustedAppsListPageLocation, TrustedAppsListPageState } from '../state'; import { initialTrustedAppsPageState } from './builders'; import { trustedAppsPageReducer } from './reducer'; import { createTrustedAppsPageMiddleware } from './middleware'; +import { Immutable } from '../../../../../common/endpoint/types'; const initialNow = 111111; const dateNowMock = jest.fn(); @@ -32,7 +33,7 @@ dateNowMock.mockReturnValue(initialNow); Date.now = dateNowMock; -const initialState = initialTrustedAppsPageState(); +const initialState: Immutable = initialTrustedAppsPageState(); const createGetTrustedListAppsResponse = (pagination: Partial) => { const fullPagination = { ...createDefaultPagination(), ...pagination }; @@ -88,6 +89,15 @@ describe('middleware', () => { }; }; + const createLocationState = ( + params?: Partial + ): TrustedAppsListPageLocation => { + return { + ...initialState.location, + ...(params ?? {}), + }; + }; + beforeEach(() => { dateNowMock.mockReturnValue(initialNow); }); @@ -103,7 +113,10 @@ describe('middleware', () => { describe('refreshing list resource state', () => { it('refreshes the list when location changes and data gets outdated', async () => { const pagination = { pageIndex: 2, pageSize: 50 }; - const location = { page_index: 2, page_size: 50, show: undefined, view_type: 'grid' }; + const location = createLocationState({ + page_index: 2, + page_size: 50, + }); const service = createTrustedAppsServiceMock(); const { store, spyMiddleware } = createStoreSetup(service); @@ -137,7 +150,10 @@ describe('middleware', () => { it('does not refresh the list when location changes and data does not get outdated', async () => { const pagination = { pageIndex: 2, pageSize: 50 }; - const location = { page_index: 2, page_size: 50, show: undefined, view_type: 'grid' }; + const location = createLocationState({ + page_index: 2, + page_size: 50, + }); const service = createTrustedAppsServiceMock(); const { store, spyMiddleware } = createStoreSetup(service); @@ -162,7 +178,7 @@ describe('middleware', () => { it('refreshes the list when data gets outdated with and outdate action', async () => { const newNow = 222222; const pagination = { pageIndex: 0, pageSize: 10 }; - const location = { page_index: 0, page_size: 10, show: undefined, view_type: 'grid' }; + const location = createLocationState(); const service = createTrustedAppsServiceMock(); const { store, spyMiddleware } = createStoreSetup(service); @@ -225,7 +241,10 @@ describe('middleware', () => { freshDataTimestamp: initialNow, }, active: true, - location: { page_index: 2, page_size: 50, show: undefined, view_type: 'grid' }, + location: createLocationState({ + page_index: 2, + page_size: 50, + }), }); const infiniteLoopTest = async () => { @@ -241,7 +260,7 @@ describe('middleware', () => { const entry = createSampleTrustedApp(3); const notFoundError = createServerApiError('Not Found'); const pagination = { pageIndex: 0, pageSize: 10 }; - const location = { page_index: 0, page_size: 10, show: undefined, view_type: 'grid' }; + const location = createLocationState(); const getTrustedAppsListResponse = createGetTrustedListAppsResponse(pagination); const listView = createLoadedListViewWithPagination(initialNow, pagination); const listViewNew = createLoadedListViewWithPagination(newNow, pagination); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts index 17c84ce35a7a6..afec5b1666db5 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts @@ -55,7 +55,13 @@ import { trustedAppsListPageActive, entriesExistState, policiesState, + isEdit, + isFetchingEditTrustedAppItem, + editItemId, + editingTrustedApp, + getListItems, } from './selectors'; +import { toNewTrustedApp } from '../service/to_new_trusted_app'; const createTrustedAppsListResourceStateChangedAction = ( newState: Immutable> @@ -140,9 +146,26 @@ const submitCreationIfNeeded = async ( store: ImmutableMiddlewareAPI, trustedAppsService: TrustedAppsService ) => { - const submissionResourceState = getCreationSubmissionResourceState(store.getState()); - const isValid = isCreationDialogFormValid(store.getState()); - const entry = getCreationDialogFormEntry(store.getState()); + const currentState = store.getState(); + const submissionResourceState = getCreationSubmissionResourceState(currentState); + const isValid = isCreationDialogFormValid(currentState); + const entry = getCreationDialogFormEntry(currentState); + const editMode = isEdit(currentState); + + // FIXME: Implement PUT API for updating Trusted App + if (editMode) { + // eslint-disable-next-line no-console + console.warn('PUT Trusted APP API missing'); + store.dispatch( + createTrustedAppCreationSubmissionResourceStateChanged({ + type: 'LoadedResourceState', + data: entry as TrustedApp, + }) + ); + store.dispatch({ + type: 'trustedAppsListDataOutdated', + }); + } if (isStaleResourceState(submissionResourceState) && entry !== undefined && isValid) { store.dispatch( @@ -316,6 +339,56 @@ export const retrieveListOfPoliciesIfNeeded = async ( } }; +const fetchEditTrustedAppIfNeeded = async ( + { getState, dispatch }: ImmutableMiddlewareAPI, + trustedAppsService: TrustedAppsService +) => { + const currentState = getState(); + const isPageActive = trustedAppsListPageActive(currentState); + const isEditFlow = isEdit(currentState); + const isAlreadyFetching = isFetchingEditTrustedAppItem(currentState); + const editTrustedAppId = editItemId(currentState); + + if (isPageActive && isEditFlow && editTrustedAppId && !isAlreadyFetching) { + let trustedAppForEdit = editingTrustedApp(currentState); + + // If Trusted App is already loaded, then do nothing + if (trustedAppForEdit && trustedAppForEdit.id === editTrustedAppId) { + return; + } + + // See if we can get the Trusted App record from the current list of Trusted Apps being displayed + trustedAppForEdit = getListItems(currentState).find((ta) => ta.id === editTrustedAppId); + if (trustedAppForEdit) { + dispatch({ + type: 'trustedAppCreationEditItemStateChanged', + payload: { + type: 'LoadedResourceState', + data: trustedAppForEdit, + }, + }); + + dispatch({ + type: 'trustedAppCreationDialogFormStateUpdated', + payload: { + entry: toNewTrustedApp(trustedAppForEdit), + isValid: true, + }, + }); + return; + } + + // Retrieve Trusted App record via API. This would be the case when linking from another place or + // using an UUID for a Trusted App that is not currently displayed on the list view. + + // eslint-disable-next-line no-console + console.log('todo: api call'); + + // FIXME: Implement GET API + throw new Error('GET trusted app API missing!'); + } +}; + export const createTrustedAppsPageMiddleware = ( trustedAppsService: TrustedAppsService ): ImmutableMiddleware => { @@ -331,6 +404,7 @@ export const createTrustedAppsPageMiddleware = ( if (action.type === 'userChangedUrl') { updateCreationDialogIfNeeded(store); retrieveListOfPoliciesIfNeeded(store, trustedAppsService); + fetchEditTrustedAppIfNeeded(store, trustedAppsService); } if (action.type === 'trustedAppCreationDialogConfirmed') { diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.test.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.test.ts index 5f37d0d674558..6965172ef773d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.test.ts @@ -37,7 +37,13 @@ describe('reducer', () => { expect(result).toStrictEqual({ ...initialState, - location: { page_index: 5, page_size: 50, show: 'create', view_type: 'list' }, + location: { + page_index: 5, + page_size: 50, + show: 'create', + view_type: 'list', + id: undefined, + }, active: true, }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts index 2e55bd2a846db..ea7bbb44c9bf2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts @@ -30,6 +30,7 @@ import { TrustedAppCreationDialogClosed, TrustedAppsExistResponse, TrustedAppsPoliciesStateChanged, + TrustedAppCreationEditItemStateChanged, } from './action'; import { TrustedAppsListPageState } from '../state'; @@ -111,7 +112,7 @@ const trustedAppCreationDialogStarted: CaseReducer = ( + state, + action +) => { + return { + ...state, + creationDialog: { ...state.creationDialog, editItem: action.payload }, + }; +}; + const trustedAppCreationDialogConfirmed: CaseReducer = ( state ) => { @@ -198,6 +209,9 @@ export const trustedAppsPageReducer: StateReducer = ( case 'trustedAppCreationDialogFormStateUpdated': return trustedAppCreationDialogFormStateUpdated(state, action); + case 'trustedAppCreationEditItemStateChanged': + return handleUpdateToEditItemState(state, action); + case 'trustedAppCreationDialogConfirmed': return trustedAppCreationDialogConfirmed(state, action); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts index 04ef36ffb2c68..65bb0abe4be46 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts @@ -131,7 +131,7 @@ export const getDeletionDialogEntry = ( }; export const isCreationDialogLocation = (state: Immutable): boolean => { - return state.location.show === 'create'; + return !!state.location.show; }; export const getCreationSubmissionResourceState = ( @@ -200,3 +200,36 @@ export const listOfPolicies: ( ) => Immutable = createSelector(policiesState, (policies) => { return isLoadedResourceState(policies) ? policies.data.items : []; }); + +export const isEdit: (state: Immutable) => boolean = createSelector( + getCurrentLocation, + ({ show }) => { + return show === 'edit'; + } +); + +export const editItemId: ( + state: Immutable +) => string | undefined = createSelector(getCurrentLocation, ({ id }) => { + return id; +}); + +export const editItemState: ( + state: Immutable +) => Immutable['creationDialog']['editItem'] = (state) => { + return state.creationDialog.editItem; +}; + +export const isFetchingEditTrustedAppItem: ( + state: Immutable +) => boolean = createSelector(editItemState, (editTrustedAppState) => { + return editTrustedAppState ? isLoadingResourceState(editTrustedAppState) : false; +}); + +export const editingTrustedApp: ( + state: Immutable +) => undefined | Immutable = createSelector(editItemState, (editTrustedAppState) => { + if (editTrustedAppState && isLoadedResourceState(editTrustedAppState)) { + return editTrustedAppState.data; + } +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_flyout.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_flyout.tsx index 2b3390ff36bc3..369553555f5ea 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_flyout.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_flyout.tsx @@ -24,16 +24,20 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { useDispatch } from 'react-redux'; import { CreateTrustedAppForm, CreateTrustedAppFormProps } from './create_trusted_app_form'; import { + getCreationDialogFormEntry, getCreationError, isCreationDialogFormValid, isCreationInProgress, isCreationSuccessful, + isEdit, listOfPolicies, loadingPolicies, } from '../../store/selectors'; import { AppAction } from '../../../../../common/store/actions'; import { useTrustedAppsSelector } from '../hooks'; + import { ABOUT_TRUSTED_APPS, CREATE_TRUSTED_APP_ERROR } from '../translations'; +import { defaultNewTrustedApp } from '../../store/builders'; type CreateTrustedAppFlyoutProps = Omit; export const CreateTrustedAppFlyout = memo( @@ -46,6 +50,8 @@ export const CreateTrustedAppFlyout = memo( const isFormValid = useTrustedAppsSelector(isCreationDialogFormValid); const isLoadingPolicies = useTrustedAppsSelector(loadingPolicies); const policyList = useTrustedAppsSelector(listOfPolicies); + const isEditMode = useTrustedAppsSelector(isEdit); + const formValues = useTrustedAppsSelector(getCreationDialogFormEntry) || defaultNewTrustedApp(); const dataTestSubj = flyoutProps['data-test-subj']; @@ -73,16 +79,19 @@ export const CreateTrustedAppFlyout = memo( }, [dataTestSubj] ); + const handleCancelClick = useCallback(() => { if (creationInProgress) { return; } onClose(); }, [onClose, creationInProgress]); + const handleSaveClick = useCallback( () => dispatch({ type: 'trustedAppCreationDialogConfirmed' }), [dispatch] ); + const handleFormOnChange = useCallback( (newFormState) => { dispatch({ @@ -105,25 +114,35 @@ export const CreateTrustedAppFlyout = memo(

- + {isEditMode ? ( + + ) : ( + + )}

- -

{ABOUT_TRUSTED_APPS}

- -
+ {!isEditMode && ( + +

{ABOUT_TRUSTED_APPS}

+ +
+ )}
@@ -151,10 +170,17 @@ export const CreateTrustedAppFlyout = memo( isLoading={creationInProgress} data-test-subj={getTestId('createButton')} > - + {isEditMode ? ( + + ) : ( + + )} diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx index 3a506d169f8c2..0d9abcdee96b4 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx @@ -9,20 +9,44 @@ import React from 'react'; import * as reactTestingLibrary from '@testing-library/react'; import { fireEvent, getByTestId } from '@testing-library/dom'; -import { ConditionEntryField, OperatingSystem } from '../../../../../../common/endpoint/types'; +import { + ConditionEntryField, + NewTrustedApp, + OperatingSystem, +} from '../../../../../../common/endpoint/types'; import { AppContextTestRender, createAppRootMockRenderer, } from '../../../../../common/mock/endpoint'; import { CreateTrustedAppForm, CreateTrustedAppFormProps } from './create_trusted_app_form'; +import { defaultNewTrustedApp } from '../../store/builders'; +import { forceHTMLElementOffsetWidth } from './effected_policy_select/test_utils'; +import { EndpointDocGenerator } from '../../../../../../common/endpoint/generate_data'; -describe('When showing the Trusted App Create Form', () => { +describe('When using the Trusted App Form', () => { const dataTestSubjForForm = 'createForm'; - type RenderResultType = ReturnType; + const generator = new EndpointDocGenerator('effected-policy-select'); + + let resetHTMLElementOffsetWidth: ReturnType; - let render: () => RenderResultType; + let mockedContext: AppContextTestRender; let formProps: jest.Mocked; + let renderResult: ReturnType; + + // As the form's `onChange()` callback is executed, this variable will + // hold the latest updated trusted app. Use it to re-render + let latestUpdatedTrustedApp: NewTrustedApp; + + const getUI = () => ; + const render = () => { + return (renderResult = mockedContext.render(getUI())); + }; + const rerender = () => renderResult.rerender(getUI()); + const rerenderWithLatestTrustedApp = () => { + formProps.trustedApp = latestUpdatedTrustedApp; + rerender(); + }; // Some helpers const setTextFieldValue = (textField: HTMLInputElement | HTMLTextAreaElement, value: string) => { @@ -33,35 +57,27 @@ describe('When showing the Trusted App Create Form', () => { fireEvent.blur(textField); }); }; - const getNameField = ( - renderResult: RenderResultType, - dataTestSub: string = dataTestSubjForForm - ): HTMLInputElement => { + const getNameField = (dataTestSub: string = dataTestSubjForForm): HTMLInputElement => { return renderResult.getByTestId(`${dataTestSub}-nameTextField`) as HTMLInputElement; }; - const getOsField = ( - renderResult: RenderResultType, - dataTestSub: string = dataTestSubjForForm - ): HTMLButtonElement => { + const getOsField = (dataTestSub: string = dataTestSubjForForm): HTMLButtonElement => { return renderResult.getByTestId(`${dataTestSub}-osSelectField`) as HTMLButtonElement; }; - const getDescriptionField = ( - renderResult: RenderResultType, - dataTestSub: string = dataTestSubjForForm - ): HTMLTextAreaElement => { + const getGlobalSwitchField = (dataTestSub: string = dataTestSubjForForm): HTMLButtonElement => { + return renderResult.getByTestId( + `${dataTestSub}-effectedPolicies-globalSwitch` + ) as HTMLButtonElement; + }; + const getDescriptionField = (dataTestSub: string = dataTestSubjForForm): HTMLTextAreaElement => { return renderResult.getByTestId(`${dataTestSub}-descriptionField`) as HTMLTextAreaElement; }; const getCondition = ( - renderResult: RenderResultType, index: number = 0, dataTestSub: string = dataTestSubjForForm ): HTMLElement => { return renderResult.getByTestId(`${dataTestSub}-conditionsBuilder-group1-entry${index}`); }; - const getAllConditions = ( - renderResult: RenderResultType, - dataTestSub: string = dataTestSubjForForm - ): HTMLElement[] => { + const getAllConditions = (dataTestSub: string = dataTestSubjForForm): HTMLElement[] => { return Array.from( renderResult.getByTestId(`${dataTestSub}-conditionsBuilder-group1-entries`).children ) as HTMLElement[]; @@ -76,7 +92,6 @@ describe('When showing the Trusted App Create Form', () => { return getByTestId(condition, `${condition.dataset.testSubj}-value`) as HTMLInputElement; }; const getConditionBuilderAndButton = ( - renderResult: RenderResultType, dataTestSub: string = dataTestSubjForForm ): HTMLButtonElement => { return renderResult.getByTestId( @@ -84,68 +99,83 @@ describe('When showing the Trusted App Create Form', () => { ) as HTMLButtonElement; }; const getConditionBuilderAndConnectorBadge = ( - renderResult: RenderResultType, dataTestSub: string = dataTestSubjForForm ): HTMLElement => { return renderResult.getByTestId(`${dataTestSub}-conditionsBuilder-group1-andConnector`); }; - const getAllValidationErrors = (renderResult: RenderResultType): HTMLElement[] => { + const getAllValidationErrors = (): HTMLElement[] => { return Array.from(renderResult.container.querySelectorAll('.euiFormErrorText')); }; beforeEach(() => { - const mockedContext = createAppRootMockRenderer(); + resetHTMLElementOffsetWidth = forceHTMLElementOffsetWidth(); + + mockedContext = createAppRootMockRenderer(); + + latestUpdatedTrustedApp = defaultNewTrustedApp(); formProps = { 'data-test-subj': dataTestSubjForForm, - onChange: jest.fn(), + trustedApp: latestUpdatedTrustedApp, + onChange: jest.fn((updates) => { + latestUpdatedTrustedApp = updates.item; + }), policies: { options: [], }, }; - render = () => mockedContext.render(); }); - it('should show Name as required', () => { - expect(getNameField(render()).required).toBe(true); + afterEach(() => { + resetHTMLElementOffsetWidth(); + reactTestingLibrary.cleanup(); }); - it('should default OS to Windows', () => { - expect(getOsField(render()).textContent).toEqual('Windows'); - }); + describe('and the form is rendered', () => { + beforeEach(() => render()); - it('should allow user to select between 3 OSs', () => { - const renderResult = render(); - const osField = getOsField(renderResult); - reactTestingLibrary.act(() => { - fireEvent.click(osField, { button: 1 }); + it('should show Name as required', () => { + expect(getNameField().required).toBe(true); }); - const options = Array.from( - renderResult.baseElement.querySelectorAll( - '.euiSuperSelect__listbox button.euiSuperSelect__item' - ) - ).map((button) => button.textContent); - expect(options).toEqual(['Mac', 'Windows', 'Linux']); - }); - it('should show Description as optional', () => { - expect(getDescriptionField(render()).required).toBe(false); - }); + it('should default OS to Windows', () => { + expect(getOsField().textContent).toEqual('Windows'); + }); - it('should NOT initially show any inline validation errors', () => { - expect(render().container.querySelectorAll('.euiFormErrorText').length).toBe(0); - }); + it('should allow user to select between 3 OSs', () => { + const osField = getOsField(); + reactTestingLibrary.act(() => { + fireEvent.click(osField, { button: 1 }); + }); + const options = Array.from( + renderResult.baseElement.querySelectorAll( + '.euiSuperSelect__listbox button.euiSuperSelect__item' + ) + ).map((button) => button.textContent); + expect(options).toEqual(['Mac', 'Windows', 'Linux']); + }); - it('should show top-level Errors', () => { - formProps.isInvalid = true; - formProps.error = 'a top level error'; - const { queryByText } = render(); - expect(queryByText(formProps.error as string)).not.toBeNull(); + it('should show Description as optional', () => { + expect(getDescriptionField().required).toBe(false); + }); + + it('should NOT initially show any inline validation errors', () => { + expect(renderResult.container.querySelectorAll('.euiFormErrorText').length).toBe(0); + }); + + it('should show top-level Errors', () => { + formProps.isInvalid = true; + formProps.error = 'a top level error'; + rerender(); + expect(renderResult.queryByText(formProps.error as string)).not.toBeNull(); + }); }); describe('the condition builder component', () => { + beforeEach(() => render()); + it('should show an initial condition entry with labels', () => { - const defaultCondition = getCondition(render()); + const defaultCondition = getCondition(); const labels = Array.from( defaultCondition.querySelectorAll('.euiFormRow__labelWrapper') ).map((label) => (label.textContent || '').trim()); @@ -153,13 +183,12 @@ describe('When showing the Trusted App Create Form', () => { }); it('should not allow the entry to be removed if its the only one displayed', () => { - const defaultCondition = getCondition(render()); + const defaultCondition = getCondition(); expect(getConditionRemoveButton(defaultCondition).disabled).toBe(true); }); it('should display 2 options for Field', () => { - const renderResult = render(); - const conditionFieldSelect = getConditionFieldSelect(getCondition(renderResult)); + const conditionFieldSelect = getConditionFieldSelect(getCondition()); reactTestingLibrary.act(() => { fireEvent.click(conditionFieldSelect, { button: 1 }); }); @@ -176,53 +205,103 @@ describe('When showing the Trusted App Create Form', () => { }); it('should show the value field as required', () => { - expect(getConditionValue(getCondition(render())).required).toEqual(true); + expect(getConditionValue(getCondition()).required).toEqual(true); }); it('should display the `AND` button', () => { - const andButton = getConditionBuilderAndButton(render()); + const andButton = getConditionBuilderAndButton(); expect(andButton.textContent).toEqual('AND'); expect(andButton.disabled).toEqual(false); }); describe('and when the AND button is clicked', () => { - let renderResult: RenderResultType; - beforeEach(() => { - renderResult = render(); - const andButton = getConditionBuilderAndButton(renderResult); + const andButton = getConditionBuilderAndButton(); reactTestingLibrary.act(() => { fireEvent.click(andButton, { button: 1 }); }); + // re-render with updated `newTrustedApp` + formProps.trustedApp = formProps.onChange.mock.calls[0][0].item; + rerender(); }); - it('should add a new condition entry when `AND` is clicked with no labels', () => { - const condition2 = getCondition(renderResult, 1); + it('should add a new condition entry when `AND` is clicked with no column labels', () => { + const condition2 = getCondition(1); expect(condition2.querySelectorAll('.euiFormRow__labelWrapper')).toHaveLength(0); }); it('should have remove buttons enabled when multiple conditions are present', () => { - getAllConditions(renderResult).forEach((condition) => { + getAllConditions().forEach((condition) => { expect(getConditionRemoveButton(condition).disabled).toBe(false); }); }); it('should show the AND visual connector when multiple entries are present', () => { - expect(getConditionBuilderAndConnectorBadge(renderResult).textContent).toEqual('AND'); + expect(getConditionBuilderAndConnectorBadge().textContent).toEqual('AND'); }); }); }); - describe('and the user visits required fields but does not fill them out', () => { - let renderResult: RenderResultType; + describe('the Policy Selection area', () => { + it('should show loader when setting `policies.isLoading` to true', () => { + formProps.policies.isLoading = true; + render(); + expect( + renderResult.getByTestId(`${dataTestSubjForForm}-effectedPolicies-policiesSelectable`) + .textContent + ).toEqual('Loading options'); + }); + + describe('and policies exist', () => { + beforeEach(() => { + const policy = generator.generatePolicyPackagePolicy(); + policy.name = 'test policy A'; + policy.id = '123'; + + formProps.policies.options = [policy]; + }); + + it('should display the policies available, but disabled if ', () => { + render(); + expect(renderResult.getByTestId('policy-123')); + }); + + it('should have `global` switch on if effective scope is global and policy options disabled', () => { + render(); + expect(getGlobalSwitchField().getAttribute('aria-checked')).toEqual('true'); + expect(renderResult.getByTestId('policy-123').getAttribute('aria-disabled')).toEqual( + 'true' + ); + expect(renderResult.getByTestId('policy-123').getAttribute('aria-selected')).toEqual( + 'false' + ); + }); + it('should have specific policies checked if scope is per-policy', () => { + (formProps.trustedApp as NewTrustedApp).effectScope = { + type: 'policy', + policies: ['123'], + }; + render(); + expect(getGlobalSwitchField().getAttribute('aria-checked')).toEqual('false'); + expect(renderResult.getByTestId('policy-123').getAttribute('aria-disabled')).toEqual( + 'false' + ); + expect(renderResult.getByTestId('policy-123').getAttribute('aria-selected')).toEqual( + 'true' + ); + }); + }); + }); + + describe('and the user visits required fields but does not fill them out', () => { beforeEach(() => { - renderResult = render(); + render(); reactTestingLibrary.act(() => { - fireEvent.blur(getNameField(renderResult)); + fireEvent.blur(getNameField()); }); reactTestingLibrary.act(() => { - fireEvent.blur(getConditionValue(getCondition(renderResult))); + fireEvent.blur(getConditionValue(getCondition())); }); }); @@ -235,69 +314,48 @@ describe('When showing the Trusted App Create Form', () => { }); it('should NOT display any other errors', () => { - expect(getAllValidationErrors(renderResult)).toHaveLength(2); - }); - - it('should call change callback with isValid set to false and contain the new item', () => { - expect(formProps.onChange).toHaveBeenCalledWith({ - isValid: false, - item: { - name: '', - description: '', - os: OperatingSystem.WINDOWS, - effectScope: { type: 'global' }, - entries: [ - { - field: ConditionEntryField.HASH, - operator: 'included', - type: 'match', - value: '', - }, - ], - }, - }); + expect(getAllValidationErrors()).toHaveLength(2); }); }); describe('and invalid data is entered', () => { - let renderResult: RenderResultType; - - beforeEach(() => { - renderResult = render(); - }); + beforeEach(() => render()); it('should validate that Name has a non empty space value', () => { - setTextFieldValue(getNameField(renderResult), ' '); + setTextFieldValue(getNameField(), ' '); expect(renderResult.getByText('Name is required')); }); it('should validate invalid Hash value', () => { - setTextFieldValue(getConditionValue(getCondition(renderResult)), 'someHASH'); + setTextFieldValue(getConditionValue(getCondition()), 'someHASH'); expect(renderResult.getByText('[1] Invalid hash value')); }); it('should validate that a condition value has a non empty space value', () => { - setTextFieldValue(getConditionValue(getCondition(renderResult)), ' '); + setTextFieldValue(getConditionValue(getCondition()), ' '); expect(renderResult.getByText('[1] Field entry must have a value')); }); it('should validate all condition values (when multiples exist) have non empty space value', () => { - const andButton = getConditionBuilderAndButton(renderResult); + const andButton = getConditionBuilderAndButton(); reactTestingLibrary.act(() => { fireEvent.click(andButton, { button: 1 }); }); + rerenderWithLatestTrustedApp(); + + setTextFieldValue(getConditionValue(getCondition()), 'someHASH'); + rerenderWithLatestTrustedApp(); - setTextFieldValue(getConditionValue(getCondition(renderResult)), 'someHASH'); expect(renderResult.getByText('[2] Field entry must have a value')); }); it('should validate multiple errors in form', () => { - const andButton = getConditionBuilderAndButton(renderResult); + const andButton = getConditionBuilderAndButton(); reactTestingLibrary.act(() => { fireEvent.click(andButton, { button: 1 }); }); - setTextFieldValue(getConditionValue(getCondition(renderResult)), 'someHASH'); + setTextFieldValue(getConditionValue(getCondition()), 'someHASH'); expect(renderResult.getByText('[1] Invalid hash value')); expect(renderResult.getByText('[2] Field entry must have a value')); }); @@ -305,15 +363,18 @@ describe('When showing the Trusted App Create Form', () => { describe('and all required data passes validation', () => { it('should call change callback with isValid set to true and contain the new item', () => { - const renderResult = render(); - setTextFieldValue(getNameField(renderResult), 'Some Process'); - setTextFieldValue( - getConditionValue(getCondition(renderResult)), - 'e50fb1a0e5fff590ece385082edc6c41' - ); - setTextFieldValue(getDescriptionField(renderResult), 'some description'); - - expect(getAllValidationErrors(renderResult)).toHaveLength(0); + render(); + + setTextFieldValue(getNameField(), 'Some Process'); + rerenderWithLatestTrustedApp(); + + setTextFieldValue(getConditionValue(getCondition()), 'e50fb1a0e5fff590ece385082edc6c41'); + rerenderWithLatestTrustedApp(); + + setTextFieldValue(getDescriptionField(), 'some description'); + rerenderWithLatestTrustedApp(); + + expect(getAllValidationErrors()).toHaveLength(0); expect(formProps.onChange).toHaveBeenLastCalledWith({ isValid: true, item: { diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx index fef0dca64f826..3888aedd75130 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx @@ -10,6 +10,7 @@ import { EuiFieldText, EuiForm, EuiFormRow, + EuiHorizontalRule, EuiSuperSelect, EuiSuperSelectOption, EuiTextArea, @@ -20,6 +21,7 @@ import { ConditionEntryField, EffectScope, MacosLinuxConditionEntry, + MaybeImmutable, NewTrustedApp, OperatingSystem, } from '../../../../../../common/endpoint/types'; @@ -28,9 +30,10 @@ import { isValidHash } from '../../../../../../common/endpoint/validation/truste import { isGlobalEffectScope, isMacosLinuxTrustedAppCondition, + isPolicyEffectScope, isWindowsTrustedAppCondition, } from '../../state/type_guards'; -import { defaultConditionEntry, defaultNewTrustedApp } from '../../store/builders'; +import { defaultConditionEntry } from '../../store/builders'; import { OS_TITLES } from '../translations'; import { LogicalConditionBuilder, LogicalConditionBuilderProps } from './logical_condition'; import { @@ -80,7 +83,7 @@ const addResultToValidation = ( validation.result[field]!.isInvalid = true; }; -const validateFormValues = (values: NewTrustedApp): ValidationResult => { +const validateFormValues = (values: MaybeImmutable): ValidationResult => { let isValid: ValidationResult['isValid'] = true; const validation: ValidationResult = { isValid, @@ -166,17 +169,18 @@ export type CreateTrustedAppFormProps = Pick< EuiFormProps, 'className' | 'data-test-subj' | 'isInvalid' | 'error' | 'invalidCallout' > & { + /** The trusted app values that will be passed to the form */ + trustedApp: MaybeImmutable; onChange: (state: TrustedAppFormState) => void; + /** Setting passed on to the EffectedPolicySelect component */ + policies: Pick; /** if form should be shown full width of parent container */ fullWidth?: boolean; - /** Setting passed on to the EffectedPolicySelect component */ - policies: { - options: EffectedPolicySelectProps['options']; - isLoading?: EffectedPolicySelectProps['isLoading']; - }; }; export const CreateTrustedAppForm = memo( - ({ fullWidth, onChange, policies = { options: [] }, ...formProps }) => { + ({ fullWidth, onChange, trustedApp: _trustedApp, policies = { options: [] }, ...formProps }) => { + const trustedApp = _trustedApp as NewTrustedApp; + const dataTestSubj = formProps['data-test-subj']; const osOptions: Array> = useMemo( @@ -184,17 +188,15 @@ export const CreateTrustedAppForm = memo( [] ); - const [formValues, setFormValues] = useState(defaultNewTrustedApp()); - // We create local state for the list of policies because we want the selected policies to // persist while the user is on the form and possibly toggling between global/non-global const [selectedPolicies, setSelectedPolicies] = useState({ - isGlobal: isGlobalEffectScope(formValues.effectScope), + isGlobal: isGlobalEffectScope(trustedApp.effectScope), selected: [], }); const [validationResult, setValidationResult] = useState(() => - validateFormValues(formValues) + validateFormValues(trustedApp) ); const [wasVisited, setWasVisited] = useState< @@ -214,42 +216,52 @@ export const CreateTrustedAppForm = memo( [dataTestSubj] ); + const notifyOfChange = useCallback( + (updatedFormValues: TrustedAppFormState['item']) => { + const updatedValidationResult = validateFormValues(updatedFormValues); + + setValidationResult(updatedValidationResult); + + onChange({ + item: updatedFormValues, + isValid: updatedValidationResult.isValid, + }); + }, + [onChange] + ); + const handleAndClick = useCallback(() => { - setFormValues( - (prevState): NewTrustedApp => { - if (prevState.os === OperatingSystem.WINDOWS) { - return { - ...prevState, - entries: [...prevState.entries, defaultConditionEntry()].filter( - isWindowsTrustedAppCondition - ), - }; - } else { - return { - ...prevState, - entries: [ - ...prevState.entries.filter(isMacosLinuxTrustedAppCondition), - defaultConditionEntry(), - ], - }; - } - } - ); - }, [setFormValues]); + if (trustedApp.os === OperatingSystem.WINDOWS) { + notifyOfChange({ + ...trustedApp, + entries: [...trustedApp.entries, defaultConditionEntry()].filter( + isWindowsTrustedAppCondition + ), + }); + } else { + notifyOfChange({ + ...trustedApp, + entries: [ + ...trustedApp.entries.filter(isMacosLinuxTrustedAppCondition), + defaultConditionEntry(), + ], + }); + } + }, [notifyOfChange, trustedApp]); const handleDomChangeEvents = useCallback< ChangeEventHandler - >(({ target: { name, value } }) => { - setFormValues( - (prevState): NewTrustedApp => { - return { - ...prevState, - [name]: value, - }; - } - ); - }, []); + >( + ({ target: { name, value } }) => { + notifyOfChange({ + ...trustedApp, + [name]: value, + }); + }, + [notifyOfChange, trustedApp] + ); + // Handles keeping track if an input form field has been visited const handleDomBlurEvents = useCallback>( ({ target: { name } }) => { setWasVisited((prevState) => { @@ -262,77 +274,73 @@ export const CreateTrustedAppForm = memo( [] ); - const handleOsChange = useCallback<(v: OperatingSystem) => void>((newOsValue) => { - setFormValues( - (prevState): NewTrustedApp => { - const updatedState: NewTrustedApp = { + const handleOsChange = useCallback<(v: OperatingSystem) => void>( + (newOsValue) => { + setWasVisited((prevState) => { + return { ...prevState, - entries: [], - os: newOsValue, + os: true, }; - if (updatedState.os !== OperatingSystem.WINDOWS) { - updatedState.entries.push( - ...(prevState.entries.filter((entry) => - isMacosLinuxTrustedAppCondition(entry) - ) as MacosLinuxConditionEntry[]) - ); - if (updatedState.entries.length === 0) { - updatedState.entries.push(defaultConditionEntry()); - } - } else { - updatedState.entries.push(...prevState.entries); + }); + + const updatedState: NewTrustedApp = { + ...trustedApp, + entries: [], + os: newOsValue, + }; + if (updatedState.os !== OperatingSystem.WINDOWS) { + updatedState.entries.push( + ...(trustedApp.entries.filter((entry) => + isMacosLinuxTrustedAppCondition(entry) + ) as MacosLinuxConditionEntry[]) + ); + if (updatedState.entries.length === 0) { + updatedState.entries.push(defaultConditionEntry()); } - return updatedState; + } else { + updatedState.entries.push(...trustedApp.entries); } - ); - setWasVisited((prevState) => { - return { - ...prevState, - os: true, - }; - }); - }, []); - const handleEntryRemove = useCallback((entry: NewTrustedApp['entries'][0]) => { - setFormValues( - (prevState): NewTrustedApp => { - return { - ...prevState, - entries: prevState.entries.filter((item) => item !== entry), - } as NewTrustedApp; - } - ); - }, []); + notifyOfChange(updatedState); + }, + [notifyOfChange, trustedApp] + ); + + const handleEntryRemove = useCallback( + (entry: NewTrustedApp['entries'][0]) => { + notifyOfChange({ + ...trustedApp, + entries: trustedApp.entries.filter((item) => item !== entry), + } as NewTrustedApp); + }, + [notifyOfChange, trustedApp] + ); const handleEntryChange = useCallback( (newEntry, oldEntry) => { - setFormValues( - (prevState): NewTrustedApp => { - if (prevState.os === OperatingSystem.WINDOWS) { - return { - ...prevState, - entries: prevState.entries.map((item) => { - if (item === oldEntry) { - return newEntry; - } - return item; - }), - } as NewTrustedApp; - } else { - return { - ...prevState, - entries: prevState.entries.map((item) => { - if (item === oldEntry) { - return newEntry; - } - return item; - }), - } as NewTrustedApp; - } - } - ); + if (trustedApp.os === OperatingSystem.WINDOWS) { + notifyOfChange({ + ...trustedApp, + entries: trustedApp.entries.map((item) => { + if (item === oldEntry) { + return newEntry; + } + return item; + }), + } as NewTrustedApp); + } else { + notifyOfChange({ + ...trustedApp, + entries: trustedApp.entries.map((item) => { + if (item === oldEntry) { + return newEntry; + } + return item; + }), + } as NewTrustedApp); + } }, - [] + [notifyOfChange, trustedApp] ); const handleConditionBuilderOnVisited: LogicalConditionBuilderProps['onVisited'] = useCallback(() => { @@ -361,28 +369,60 @@ export const CreateTrustedAppForm = memo( }; } - setFormValues((prevState) => { - return { - ...prevState, - effectScope: newEffectedScope, - }; + notifyOfChange({ + ...trustedApp, + effectScope: newEffectedScope, }); }, - [] + [notifyOfChange, trustedApp] ); // Anytime the form values change, re-validate useEffect(() => { - setValidationResult(validateFormValues(formValues)); - }, [formValues]); + setValidationResult((prevState) => { + const newResults = validateFormValues(trustedApp); - // Anytime the form values change - validate and notify + // Only notify if the overall validation result is different + if (newResults.isValid !== prevState.isValid) { + notifyOfChange(trustedApp); + } + + return newResults; + }); + }, [notifyOfChange, trustedApp]); + + // Anytime the TrustedApp has an effective scope of `policies`, then ensure that + // those polices are selected in the UI while at teh same time preserving prior + // selections (UX requirement) useEffect(() => { - onChange({ - isValid: validationResult.isValid, - item: formValues, + setSelectedPolicies((currentSelection) => { + if (isPolicyEffectScope(trustedApp.effectScope) && policies.options.length > 0) { + const missingSelectedPolicies: EffectedPolicySelectProps['selected'] = []; + + for (const policyId of trustedApp.effectScope.policies) { + if ( + !currentSelection.selected.find( + (currentlySelectedPolicyItem) => currentlySelectedPolicyItem.id === policyId + ) + ) { + const newSelectedPolicy = policies.options.find((policy) => policy.id === policyId); + if (newSelectedPolicy) { + missingSelectedPolicies.push(newSelectedPolicy); + } + } + } + + if (missingSelectedPolicies.length) { + return { + ...currentSelection, + selected: [...currentSelection.selected, ...missingSelectedPolicies], + }; + } + } + + return currentSelection; }); - }, [formValues, onChange, validationResult.isValid]); + }, [policies.options, trustedApp.effectScope]); return ( @@ -397,7 +437,7 @@ export const CreateTrustedAppForm = memo( > ( ( error={validationResult.result.entries?.errors} > ( > + + + { @@ -33,7 +34,7 @@ describe('when using EffectedPolicySelect component', () => { let resetHTMLElementOffsetWidth: () => void; beforeAll(() => { - resetHTMLElementOffsetWidth = forceHTMLElementOffsetWith(); + resetHTMLElementOffsetWidth = forceHTMLElementOffsetWidth(); }); afterAll(() => resetHTMLElementOffsetWidth()); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/effected_policy_select/effected_policy_select.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/effected_policy_select/effected_policy_select.tsx index e6141665a5dc6..d2b92ac5a9609 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/effected_policy_select/effected_policy_select.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/effected_policy_select/effected_policy_select.tsx @@ -1,10 +1,11 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. */ -import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { memo, useCallback, useMemo } from 'react'; import { EuiCheckbox, EuiFormRow, @@ -59,12 +60,6 @@ export const EffectedPolicySelect = memo( }) => { const { formatUrl } = useFormatUrl(SecurityPageName.administration); - const [, setIsFirstRender] = useState(true); - const [selectionState, setSelectionState] = useState({ - isGlobal, - selected, - }); - const getTestId = useCallback( (suffix): string | undefined => { if (dataTestSubj) { @@ -111,18 +106,24 @@ export const EffectedPolicySelect = memo( const handleOnPolicySelectChange = useCallback< Required>['onChange'] - >((currentOptions) => { - setSelectionState((prevState) => ({ - ...prevState, - selected: currentOptions.filter((opt) => opt.checked).map((opt) => opt.policy), - })); - }, [])!; + >( + (currentOptions) => { + onChange({ + isGlobal, + selected: currentOptions.filter((opt) => opt.checked).map((opt) => opt.policy), + }); + }, + [isGlobal, onChange] + )!; const handleGlobalSwitchChange: EuiSwitchProps['onChange'] = useCallback( ({ target: { checked } }) => { - setSelectionState((prevState) => ({ ...prevState, isGlobal: checked })); + onChange({ + isGlobal: checked, + selected, + }); }, - [] + [onChange, selected] ); const listBuilderCallback: EuiSelectableProps['children'] = useCallback((list, search) => { @@ -134,17 +135,6 @@ export const EffectedPolicySelect = memo( ); }, []); - // Anytime selection state is updated, call `onChange`, but not on first render - useEffect(() => { - setIsFirstRender((isFirstRender) => { - if (isFirstRender) { - return false; - } - onChange(selectionState); - return false; - }); - }, [onChange, selectionState]); - return ( <> ( defaultMessage: 'Apply trusted application globally', } )} - checked={selectionState.isGlobal} + checked={isGlobal} onChange={handleGlobalSwitchChange} data-test-subj={getTestId('globalSwitch')} /> diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/effected_policy_select/index.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/effected_policy_select/index.ts index b4d653a98e106..2eac4598e2a6a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/effected_policy_select/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/effected_policy_select/index.ts @@ -1,7 +1,8 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. + * 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. */ export * from './effected_policy_select'; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/effected_policy_select/test_utils.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/effected_policy_select/test_utils.ts index d5af36618d208..4461dd504a7d6 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/effected_policy_select/test_utils.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/effected_policy_select/test_utils.ts @@ -1,7 +1,8 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. + * 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. */ /** @@ -16,7 +17,7 @@ * //... later * resetEnv(); */ -export const forceHTMLElementOffsetWith = (width: number = 100): (() => void) => { +export const forceHTMLElementOffsetWidth = (width: number = 100): (() => void) => { const currentOffsetDefinition = Object.getOwnPropertyDescriptor( window.HTMLElement.prototype, 'offsetWidth' diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/__snapshots__/index.test.tsx.snap index a47558257420c..03acc9b2297ad 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/__snapshots__/index.test.tsx.snap @@ -84,6 +84,15 @@ exports[`trusted_app_card TrustedAppCard should render correctly 1`] = ` items={Array []} responsive={true} /> + + Edit + + + Edit + ; + return ( + + ); }) .add('multiple entries', () => { const trustedApp: TrustedApp = createSampleTrustedApp(5); trustedApp.created_at = '2020-09-17T14:52:33.899Z'; trustedApp.entries = [PATH_CONDITION, SIGNER_CONDITION]; - return ; + return ( + + ); }) .add('longs texts', () => { const trustedApp: TrustedApp = createSampleTrustedApp(5, true); trustedApp.created_at = '2020-09-17T14:52:33.899Z'; trustedApp.entries = [PATH_CONDITION, SIGNER_CONDITION]; - return ; + return ( + + ); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/index.test.tsx index c33253058f4dd..0b1d8e0d7ac98 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/index.test.tsx @@ -15,7 +15,11 @@ describe('trusted_app_card', () => { describe('TrustedAppCard', () => { it('should render correctly', () => { const element = shallow( - {}} /> + {}} + onEdit={() => {}} + /> ); expect(element).toMatchSnapshot(); @@ -23,7 +27,11 @@ describe('trusted_app_card', () => { it('should trim long texts', () => { const element = shallow( - {}} /> + {}} + onEdit={() => {}} + /> ); expect(element).toMatchSnapshot(); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/index.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/index.tsx index 2b9acce55c032..477ede53c2009 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/index.tsx @@ -31,6 +31,7 @@ import { CARD_DELETE_BUTTON_LABEL, CONDITION_FIELD_TITLE, OPERATOR_TITLE, + CARD_EDIT_BUTTON_LABEL, } from '../../translations'; type Entry = MacosLinuxConditionEntry | WindowsConditionEntry; @@ -75,13 +76,15 @@ const getEntriesColumnDefinitions = (): Array }, ]; -interface TrustedAppCardProps { +export interface TrustedAppCardProps { trustedApp: Immutable; onDelete: (trustedApp: Immutable) => void; + onEdit: (trustedApp: Immutable) => void; } -export const TrustedAppCard = memo(({ trustedApp, onDelete }: TrustedAppCardProps) => { +export const TrustedAppCard = memo(({ trustedApp, onDelete, onEdit }: TrustedAppCardProps) => { const handleDelete = useCallback(() => onDelete(trustedApp), [onDelete, trustedApp]); + const handleEdit = useCallback(() => onEdit(trustedApp), [onEdit, trustedApp]); return ( @@ -133,6 +136,16 @@ export const TrustedAppCard = memo(({ trustedApp, onDelete }: TrustedAppCardProp responsive /> + + {CARD_EDIT_BUTTON_LABEL} + + +
+
+ +
+
@@ -592,6 +613,27 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = `
+
+
+ +
+
@@ -844,6 +886,27 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = `
+
+
+ +
+
@@ -1096,6 +1159,27 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = `
+
+
+ +
+
@@ -1348,6 +1432,27 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = `
+
+
+ +
+
@@ -1600,6 +1705,27 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = `
+
+
+ +
+
@@ -1852,6 +1978,27 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = `
+
+
+ +
+
@@ -2104,6 +2251,27 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = `
+
+
+ +
+
@@ -2356,6 +2524,27 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = `
+
+
+ +
+
@@ -2608,6 +2797,27 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = `
+
+
+ +
+
@@ -3149,6 +3359,27 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time
+
+
+ +
+
@@ -3401,6 +3632,27 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time
+
+
+ +
+
@@ -3653,6 +3905,27 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time
+
+
+ +
+
@@ -3905,6 +4178,27 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time
+
+
+ +
+
@@ -4162,8 +4456,8 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time >
-
+
+
+ +
+
+
@@ -4409,6 +4724,27 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time
+
+
+ +
+
@@ -4661,6 +4997,27 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time
+
+
+ +
+
@@ -4913,6 +5270,27 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time
+
+
+ +
+
@@ -5165,6 +5543,27 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time
+
+
+ +
+
@@ -5417,6 +5816,27 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time
+
+
+ +
+
@@ -5916,6 +6336,27 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not
+
+
+ +
+
@@ -6168,6 +6609,27 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not
+
+
+ +
+
@@ -6420,6 +6882,27 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not
+
+
+ +
+
@@ -6672,6 +7155,27 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not
+
+
+ +
+
@@ -6924,6 +7428,27 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not
+
+
+ +
+
@@ -7176,6 +7701,27 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not
+
+
+ +
+
@@ -7428,6 +7974,27 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not
+
+
+ +
+
@@ -7680,6 +8247,27 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not
+
+
+ +
+
@@ -7932,6 +8520,27 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not
+
+
+ +
+
@@ -8184,6 +8793,27 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not
+
+
+ +
+
diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_grid/index.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_grid/index.tsx index 668dc27f4a529..b1be9ba295dd9 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_grid/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_grid/index.tsx @@ -16,9 +16,11 @@ import { EuiSpacer, } from '@elastic/eui'; +import { useHistory } from 'react-router-dom'; import { Pagination } from '../../../state'; import { + getCurrentLocation, getListErrorMessage, getListItems, getListPagination, @@ -33,7 +35,8 @@ import { import { NO_RESULTS_MESSAGE } from '../../translations'; -import { TrustedAppCard } from '../trusted_app_card'; +import { TrustedAppCard, TrustedAppCardProps } from '../trusted_app_card'; +import { getTrustedAppsListPath } from '../../../../../common/routing'; export interface PaginationBarProps { pagination: Pagination; @@ -75,15 +78,31 @@ const GridMessage: FC = ({ children }) => ( ); export const TrustedAppsGrid = memo(() => { + const history = useHistory(); const pagination = useTrustedAppsSelector(getListPagination); const listItems = useTrustedAppsSelector(getListItems); const isLoading = useTrustedAppsSelector(isListLoading); const error = useTrustedAppsSelector(getListErrorMessage); + const location = useTrustedAppsSelector(getCurrentLocation); const handleTrustedAppDelete = useTrustedAppsStoreActionCallback((trustedApp) => ({ type: 'trustedAppDeletionDialogStarted', payload: { entry: trustedApp }, })); + + const handleTrustedAppEdit: TrustedAppCardProps['onEdit'] = useCallback( + (trustedApp) => { + history.push( + getTrustedAppsListPath({ + ...location, + show: 'edit', + id: trustedApp.id, + }) + ); + }, + [history, location] + ); + const handlePaginationChange = useTrustedAppsNavigateCallback(({ index, size }) => ({ page_index: index, page_size: size, @@ -114,7 +133,11 @@ export const TrustedAppsGrid = memo(() => { {listItems.map((item) => ( - + ))} diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_list/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_list/__snapshots__/index.test.tsx.snap index 5a176018f0e3f..2d1a135dff5e5 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_list/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_list/__snapshots__/index.test.tsx.snap @@ -988,6 +988,27 @@ exports[`TrustedAppsList renders correctly when item details expanded 1`] = `
+
+
+ +
+
diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_list/index.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_list/index.tsx index 74510bedc0d17..59d19b45dae26 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_list/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_list/index.tsx @@ -6,7 +6,7 @@ */ import { Dispatch } from 'redux'; -import React, { memo, ReactNode, useMemo, useState } from 'react'; +import React, { memo, ReactNode, useCallback, useMemo, useState } from 'react'; import { useDispatch } from 'react-redux'; import { EuiBasicTable, @@ -16,11 +16,12 @@ import { RIGHT_ALIGNMENT, } from '@elastic/eui'; -import { Immutable } from '../../../../../../../common/endpoint/types'; +import { useHistory } from 'react-router-dom'; +import { Immutable, TrustedApp } from '../../../../../../../common/endpoint/types'; import { AppAction } from '../../../../../../common/store/actions'; -import { TrustedApp } from '../../../../../../../common/endpoint/types/trusted_apps'; import { + getCurrentLocation, getListErrorMessage, getListItems, getListPagination, @@ -33,7 +34,8 @@ import { TextFieldValue } from '../../../../../../common/components/text_field_v import { useTrustedAppsNavigateCallback, useTrustedAppsSelector } from '../../hooks'; import { ACTIONS_COLUMN_TITLE, LIST_ACTIONS, OS_TITLES, PROPERTY_TITLES } from '../../translations'; -import { TrustedAppCard } from '../trusted_app_card'; +import { TrustedAppCard, TrustedAppCardProps } from '../trusted_app_card'; +import { getTrustedAppsListPath } from '../../../../../common/routing'; interface DetailsMap { [K: string]: ReactNode; @@ -47,27 +49,39 @@ interface TrustedAppsListContext { type ColumnsList = Array>>; type ActionsList = EuiTableActionsColumnType>['actions']; -const toggleItemDetailsInMap = ( - map: DetailsMap, - item: Immutable, - { dispatch }: TrustedAppsListContext -): DetailsMap => { +const ExpandedRowContent = memo>(({ trustedApp }) => { + const dispatch = useDispatch(); + const history = useHistory(); + const location = useTrustedAppsSelector(getCurrentLocation); + + const handleOnDelete = useCallback(() => { + dispatch({ + type: 'trustedAppDeletionDialogStarted', + payload: { entry: trustedApp }, + }); + }, [dispatch, trustedApp]); + + const handleOnEdit = useCallback(() => { + history.push( + getTrustedAppsListPath({ + ...location, + show: 'edit', + id: trustedApp.id, + }) + ); + }, [history, location, trustedApp.id]); + + return ; +}); +ExpandedRowContent.displayName = 'ExpandedRowContent'; + +const toggleItemDetailsInMap = (map: DetailsMap, item: Immutable): DetailsMap => { const changedMap = { ...map }; if (changedMap[item.id]) { delete changedMap[item.id]; } else { - changedMap[item.id] = ( - { - dispatch({ - type: 'trustedAppDeletionDialogStarted', - payload: { entry: item }, - }); - }} - /> - ); + changedMap[item.id] = ; } return changedMap; @@ -158,7 +172,7 @@ const getColumnDefinitions = (context: TrustedAppsListContext): ColumnsList => { render(item: Immutable) { return ( setItemDetailsMap(toggleItemDetailsInMap(itemDetailsMap, item, context))} + onClick={() => setItemDetailsMap(toggleItemDetailsInMap(itemDetailsMap, item))} aria-label={itemDetailsMap[item.id] ? 'Collapse' : 'Expand'} iconType={itemDetailsMap[item.id] ? 'arrowUp' : 'arrowDown'} data-test-subj="trustedAppsListItemExpandButton" @@ -179,7 +193,7 @@ export const TrustedAppsList = memo(() => { getColumnDefinitions({ dispatch, detailsMapState: [detailsMap, setDetailsMap] }), - [dispatch, detailsMap, setDetailsMap] + [dispatch, detailsMap] )} items={useMemo(() => [...listItems], [listItems])} error={useTrustedAppsSelector(getListErrorMessage)} diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts index ee2d23803191f..8689f80aa42a1 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts @@ -123,6 +123,13 @@ export const CARD_DELETE_BUTTON_LABEL = i18n.translate( } ); +export const CARD_EDIT_BUTTON_LABEL = i18n.translate( + 'xpack.securitySolution.trustedapps.card.editButtonLabel', + { + defaultMessage: 'Edit', + } +); + export const GRID_VIEW_TOGGLE_LABEL = i18n.translate( 'xpack.securitySolution.trustedapps.view.toggle.grid', { diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx index ef20ab1df45b7..b2586253be636 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx @@ -27,7 +27,7 @@ import { } from '../../../../../../fleet/common'; import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_data'; import { isLoadedResourceState } from '../state'; -import { forceHTMLElementOffsetWith } from './components/effected_policy_select/test_utils'; +import { forceHTMLElementOffsetWidth } from './components/effected_policy_select/test_utils'; jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ htmlIdGenerator: () => () => 'mockId', @@ -123,7 +123,9 @@ describe('When on the Trusted Apps Page', () => { window.scrollTo = jest.fn(); }); - describe('and there is trusted app entries', () => { + afterEach(() => reactTestingLibrary.cleanup()); + + describe('and there are trusted app entries', () => { const renderWithListData = async () => { const renderResult = render(); await act(async () => { @@ -144,9 +146,50 @@ describe('When on the Trusted Apps Page', () => { const addButton = await getByTestId('trustedAppsListAddButton'); expect(addButton.textContent).toBe('Add Trusted Application'); }); + + describe('and the edit trusted app button is clicked', () => { + let renderResult: ReturnType; + + beforeEach(async () => { + renderResult = await renderWithListData(); + act(() => { + fireEvent.click(renderResult.getByTestId('trustedAppEditButton')); + }); + }); + + it('should display the Edit flyout', () => { + expect(renderResult.getByTestId('addTrustedAppFlyout')); + }); + + it('should NOT display the about info for trusted apps', () => { + expect(renderResult.queryByTestId('addTrustedAppFlyout-about')).toBeNull(); + }); + + it('should show correct flyout title', () => { + expect(renderResult.getByTestId('addTrustedAppFlyout-headerTitle').textContent).toBe( + 'Edit trusted application' + ); + }); + + it('should display the expected text for the Save button', () => { + expect(renderResult.getByTestId('addTrustedAppFlyout-createButton').textContent).toEqual( + 'Save' + ); + }); + + describe('and when Save is clicked', () => { + // Will be unskiped once PUT API is created + it.skip('should call the correct api (PUT)', () => { + act(() => { + fireEvent.click(renderResult.getByTestId('addTrustedAppFlyout-createButton')); + }); + expect(coreStart.http.put).toHaveBeenCalledWith({}); + }); + }); + }); }); - describe('when the Add Trusted App button is clicked', () => { + describe('and the Add Trusted App button is clicked', () => { const renderAndClickAddButton = async (): Promise< ReturnType > => { @@ -181,6 +224,8 @@ describe('When on the Trusted Apps Page', () => { const flyoutTitle = getByTestId('addTrustedAppFlyout-headerTitle'); expect(flyoutTitle.textContent).toBe('Add trusted application'); + + expect(getByTestId('addTrustedAppFlyout-about')); }); it('should update the URL to indicate the flyout is opened', async () => { @@ -202,7 +247,7 @@ describe('When on the Trusted Apps Page', () => { }); it('should have list of policies populated', async () => { - const resetEnv = forceHTMLElementOffsetWith(); + const resetEnv = forceHTMLElementOffsetWidth(); const { getByTestId } = await renderAndClickAddButton(); expect(getByTestId('policy-abc123')); resetEnv(); @@ -241,12 +286,14 @@ describe('When on the Trusted Apps Page', () => { fireEvent.change(getByTestId('addTrustedAppFlyout-createForm-nameTextField'), { target: { value: 'trusted app A' }, }); - + }); + reactTestingLibrary.act(() => { fireEvent.change( getByTestId('addTrustedAppFlyout-createForm-conditionsBuilder-group1-entry0-value'), { target: { value: '44ed10b389dbcd1cf16cec79d16d7378' } } ); - + }); + reactTestingLibrary.act(() => { fireEvent.change(getByTestId('addTrustedAppFlyout-createForm-descriptionField'), { target: { value: 'let this be' }, }); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.tsx index 528b2e4a28a9d..feba6eca04856 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.tsx @@ -46,13 +46,19 @@ export const TrustedAppsPage = memo(() => { const totalItemsCount = useTrustedAppsSelector(getListTotalItemsCount); const isCheckingIfEntriesExists = useTrustedAppsSelector(checkingIfEntriesExist); const doEntriesExist = useTrustedAppsSelector(entriesExist) === true; - const handleAddButtonClick = useTrustedAppsNavigateCallback(() => ({ show: 'create' })); - const handleAddFlyoutClose = useTrustedAppsNavigateCallback(() => ({ show: undefined })); + const handleAddButtonClick = useTrustedAppsNavigateCallback(() => ({ + show: 'create', + id: undefined, + })); + const handleAddFlyoutClose = useTrustedAppsNavigateCallback(() => ({ + show: undefined, + id: undefined, + })); const handleViewTypeChange = useTrustedAppsNavigateCallback((viewType: ViewType) => ({ view_type: viewType, })); - const showCreateFlyout = location.show === 'create'; + const showCreateFlyout = !!location.show; const backButton = useMemo(() => { if (routeState && routeState.onBackButtonNavigateTo) { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index c07210f2e3eff..7d2e08b031f63 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -20659,9 +20659,7 @@ "xpack.securitySolution.trustedapps.create.os": "オペレーティングシステムを選択", "xpack.securitySolution.trustedapps.create.osRequiredMsg": "オペレーティングシステムは必須です", "xpack.securitySolution.trustedapps.createTrustedAppFlyout.cancelButton": "キャンセル", - "xpack.securitySolution.trustedapps.createTrustedAppFlyout.saveButton": "信頼できるアプリケーションを追加", "xpack.securitySolution.trustedapps.createTrustedAppFlyout.successToastTitle": "「{name}」は信頼できるアプリケーションリストに追加されました。", - "xpack.securitySolution.trustedapps.createTrustedAppFlyout.title": "信頼できるアプリケーションを追加", "xpack.securitySolution.trustedapps.deletionDialog.cancelButton": "キャンセル", "xpack.securitySolution.trustedapps.deletionDialog.confirmButton": "信頼できるアプリケーションを削除", "xpack.securitySolution.trustedapps.deletionDialog.mainMessage": "信頼できるアプリケーション「{name}」を削除しています。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 8a027942c4275..10c1349e2a9a3 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -20984,9 +20984,7 @@ "xpack.securitySolution.trustedapps.create.os": "选择操作系统", "xpack.securitySolution.trustedapps.create.osRequiredMsg": "“操作系统”必填", "xpack.securitySolution.trustedapps.createTrustedAppFlyout.cancelButton": "取消", - "xpack.securitySolution.trustedapps.createTrustedAppFlyout.saveButton": "添加受信任的应用程序", "xpack.securitySolution.trustedapps.createTrustedAppFlyout.successToastTitle": "“{name}”已添加到受信任的应用程序列表。", - "xpack.securitySolution.trustedapps.createTrustedAppFlyout.title": "添加受信任的应用程序", "xpack.securitySolution.trustedapps.deletionDialog.cancelButton": "取消", "xpack.securitySolution.trustedapps.deletionDialog.confirmButton": "移除受信任的应用程序", "xpack.securitySolution.trustedapps.deletionDialog.mainMessage": "您正在移除受信任的应用程序“{name}”。", From 318f6cfdc64041c74afb272bdf63a5edfcbca2fc Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Wed, 10 Feb 2021 11:39:02 -0500 Subject: [PATCH 06/31] [SECURITY SOLUTION][ENDPOINT] API (`PUT`) for Trusted Apps Edit flow (#90333) * New API route for Update (`PUT`) * Connect UI to Update (PUT) API * Add `version` to TrustedApp type and return it on the API responses * Refactor - moved some public/server shared modules to top-level `common/*` --- x-pack/plugins/lists/server/index.ts | 5 +- .../common/endpoint/constants.ts | 1 + .../common/endpoint/schema/trusted_apps.ts | 30 ++- .../trusted_apps/to_update_trusted_app.ts} | 17 +- .../trusted_apps/validations.ts} | 2 +- .../common/endpoint/types/trusted_apps.ts | 15 ++ .../pages/trusted_apps/service/index.ts | 18 ++ .../trusted_apps/store/middleware.test.ts | 1 + .../pages/trusted_apps/store/middleware.ts | 40 ++-- .../pages/trusted_apps/test_utils/index.ts | 1 + .../pages/trusted_apps/view/translations.ts | 2 +- .../view/trusted_apps_notifications.tsx | 29 ++- .../view/trusted_apps_page.test.tsx | 32 ++- .../endpoint/routes/trusted_apps/errors.ts | 20 ++ .../routes/trusted_apps/handlers.test.ts | 198 ++++++++++++++---- .../endpoint/routes/trusted_apps/handlers.ts | 93 ++++++-- .../endpoint/routes/trusted_apps/index.ts | 13 ++ .../routes/trusted_apps/mapping.test.ts | 50 ++++- .../endpoint/routes/trusted_apps/mapping.ts | 48 ++++- .../routes/trusted_apps/service.test.ts | 85 +++++++- .../endpoint/routes/trusted_apps/service.ts | 56 ++++- .../routes/trusted_apps/test_utils.ts | 33 +++ 22 files changed, 676 insertions(+), 113 deletions(-) rename x-pack/plugins/security_solution/{public/management/pages/trusted_apps/service/to_new_trusted_app.ts => common/endpoint/service/trusted_apps/to_update_trusted_app.ts} (57%) rename x-pack/plugins/security_solution/common/endpoint/{validation/trusted_apps.ts => service/trusted_apps/validations.ts} (93%) create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/errors.ts create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/test_utils.ts diff --git a/x-pack/plugins/lists/server/index.ts b/x-pack/plugins/lists/server/index.ts index 1ebdf9f04bf9d..250b5e79ed109 100644 --- a/x-pack/plugins/lists/server/index.ts +++ b/x-pack/plugins/lists/server/index.ts @@ -12,7 +12,10 @@ import { ListPlugin } from './plugin'; // exporting these since its required at top level in siem plugin export { ListClient } from './services/lists/list_client'; -export { CreateExceptionListItemOptions } from './services/exception_lists/exception_list_client_types'; +export { + CreateExceptionListItemOptions, + UpdateExceptionListItemOptions, +} from './services/exception_lists/exception_list_client_types'; export { ExceptionListClient } from './services/exception_lists/exception_list_client'; export type { ListPluginSetup, ListsApiRequestHandlerContext } from './types'; diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index 90e025de1dcc8..3f63ce1b10dfa 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -17,6 +17,7 @@ export const LIMITED_CONCURRENCY_ENDPOINT_COUNT = 100; export const TRUSTED_APPS_LIST_API = '/api/endpoint/trusted_apps'; export const TRUSTED_APPS_CREATE_API = '/api/endpoint/trusted_apps'; +export const TRUSTED_APPS_UPDATE_API = '/api/endpoint/trusted_apps/{id}'; export const TRUSTED_APPS_DELETE_API = '/api/endpoint/trusted_apps/{id}'; export const TRUSTED_APPS_SUMMARY_API = '/api/endpoint/trusted_apps/summary'; diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts index fb2a53d1cf919..bdc3660629366 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts @@ -7,7 +7,7 @@ import { schema, Type } from '@kbn/config-schema'; import { ConditionEntry, ConditionEntryField, OperatingSystem } from '../types'; -import { getDuplicateFields, isValidHash } from '../validation/trusted_apps'; +import { getDuplicateFields, isValidHash } from '../service/trusted_apps/validations'; export const DeleteTrustedAppsRequestSchema = { params: schema.object({ @@ -109,7 +109,8 @@ const EntriesSchema = schema.arrayOf(EntrySchemaDependingOnOS, { }); const createNewTrustedAppForOsScheme = ( osSchema: Type, - entriesSchema: Type + entriesSchema: Type, + forUpdateFlow: boolean = false ) => schema.object({ name: schema.string({ minLength: 1, maxLength: 256 }), @@ -120,7 +121,7 @@ const createNewTrustedAppForOsScheme = = [ +const NEW_TRUSTED_APP_KEYS: Array = [ 'name', 'effectScope', 'entries', 'description', 'os', + 'version', ]; -export const toNewTrustedApp = ( +export const toUpdateTrustedApp = ( trustedApp: MaybeImmutable -): NewTrustedApp => { - const newTrustedApp = defaultNewTrustedApp(); +): UpdateTrustedApp => { + const trustedAppForUpdate: UpdateTrustedApp = {} as UpdateTrustedApp; + for (const key of NEW_TRUSTED_APP_KEYS) { // This should be safe. Its needed due to the inter-dependency on property values (`os` <=> `entries`) // @ts-expect-error - newTrustedApp[key] = trustedApp[key]; + trustedAppForUpdate[key] = trustedApp[key]; } - return newTrustedApp; + return trustedAppForUpdate; }; diff --git a/x-pack/plugins/security_solution/common/endpoint/validation/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/validations.ts similarity index 93% rename from x-pack/plugins/security_solution/common/endpoint/validation/trusted_apps.ts rename to x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/validations.ts index faad639eeacb3..b0828be6af6c5 100644 --- a/x-pack/plugins/security_solution/common/endpoint/validation/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/validations.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ConditionEntry, ConditionEntryField } from '../types'; +import { ConditionEntry, ConditionEntryField } from '../../types'; const HASH_LENGTHS: readonly number[] = [ 32, // MD5 diff --git a/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts index e60ab6de14269..ce55d9e5d7e7a 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts @@ -11,6 +11,7 @@ import { DeleteTrustedAppsRequestSchema, GetTrustedAppsRequestSchema, PostTrustedAppCreateRequestSchema, + PutTrustedAppUpdateRequestSchema, } from '../schema/trusted_apps'; import { OperatingSystem } from './os'; @@ -39,6 +40,14 @@ export interface PostTrustedAppCreateResponse { data: TrustedApp; } +/** API request params for updating a Trusted App */ +export type PutTrustedAppsRequestParams = TypeOf; + +/** API Request body for Updating a new Trusted App entry */ +export type PutTrustedAppUpdateRequest = TypeOf; + +export type PutTrustedAppUpdateResponse = PostTrustedAppCreateResponse; + export interface GetTrustedAppsSummaryResponse { total: number; windows: number; @@ -95,8 +104,14 @@ export type NewTrustedApp = { effectScope: EffectScope; } & (MacosLinuxConditionEntries | WindowsConditionEntries); +/** An Update to a Trusted App Entry */ +export type UpdateTrustedApp = NewTrustedApp & { + version?: string; +}; + /** A trusted app entry */ export type TrustedApp = NewTrustedApp & { + version: string; id: string; created_at: string; created_by: string; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts index 4ad3a5f585cb5..5c43ff989594f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts @@ -11,6 +11,7 @@ import { TRUSTED_APPS_CREATE_API, TRUSTED_APPS_DELETE_API, TRUSTED_APPS_LIST_API, + TRUSTED_APPS_UPDATE_API, TRUSTED_APPS_SUMMARY_API, } from '../../../../../common/endpoint/constants'; @@ -21,6 +22,9 @@ import { PostTrustedAppCreateRequest, PostTrustedAppCreateResponse, GetTrustedAppsSummaryResponse, + PutTrustedAppUpdateRequest, + PutTrustedAppUpdateResponse, + PutTrustedAppsRequestParams, } from '../../../../../common/endpoint/types/trusted_apps'; import { resolvePathVariables } from './utils'; @@ -30,6 +34,10 @@ export interface TrustedAppsService { getTrustedAppsList(request: GetTrustedAppsListRequest): Promise; deleteTrustedApp(request: DeleteTrustedAppsRequestParams): Promise; createTrustedApp(request: PostTrustedAppCreateRequest): Promise; + updateTrustedApp( + params: PutTrustedAppsRequestParams, + request: PutTrustedAppUpdateRequest + ): Promise; getPolicyList( options?: Parameters[1] ): ReturnType; @@ -54,6 +62,16 @@ export class TrustedAppsHttpService implements TrustedAppsService { }); } + async updateTrustedApp( + params: PutTrustedAppsRequestParams, + updatedTrustedApp: PutTrustedAppUpdateRequest + ) { + return this.http.put( + resolvePathVariables(TRUSTED_APPS_UPDATE_API, params), + { body: JSON.stringify(updatedTrustedApp) } + ); + } + async getTrustedAppsSummary() { return this.http.get(TRUSTED_APPS_SUMMARY_API); } diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts index f700a88ecbf25..735f64e09a183 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts @@ -51,6 +51,7 @@ const createTrustedAppsServiceMock = (): jest.Mocked => ({ deleteTrustedApp: jest.fn(), createTrustedApp: jest.fn(), getPolicyList: jest.fn(), + updateTrustedApp: jest.fn(), }); const createStoreSetup = (trustedAppsService: TrustedAppsService) => { diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts index afec5b1666db5..976401f6f28db 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts @@ -61,7 +61,7 @@ import { editingTrustedApp, getListItems, } from './selectors'; -import { toNewTrustedApp } from '../service/to_new_trusted_app'; +import { toUpdateTrustedApp } from '../../../../../common/endpoint/service/trusted_apps/to_update_trusted_app'; const createTrustedAppsListResourceStateChangedAction = ( newState: Immutable> @@ -152,21 +152,6 @@ const submitCreationIfNeeded = async ( const entry = getCreationDialogFormEntry(currentState); const editMode = isEdit(currentState); - // FIXME: Implement PUT API for updating Trusted App - if (editMode) { - // eslint-disable-next-line no-console - console.warn('PUT Trusted APP API missing'); - store.dispatch( - createTrustedAppCreationSubmissionResourceStateChanged({ - type: 'LoadedResourceState', - data: entry as TrustedApp, - }) - ); - store.dispatch({ - type: 'trustedAppsListDataOutdated', - }); - } - if (isStaleResourceState(submissionResourceState) && entry !== undefined && isValid) { store.dispatch( createTrustedAppCreationSubmissionResourceStateChanged({ @@ -176,12 +161,27 @@ const submitCreationIfNeeded = async ( ); try { + let responseTrustedApp: TrustedApp; + + if (editMode) { + responseTrustedApp = ( + await trustedAppsService.updateTrustedApp( + { id: editItemId(currentState)! }, + // TODO: try to remove the cast + entry as PostTrustedAppCreateRequest + ) + ).data; + } else { + // TODO: try to remove the cast + responseTrustedApp = ( + await trustedAppsService.createTrustedApp(entry as PostTrustedAppCreateRequest) + ).data; + } + store.dispatch( createTrustedAppCreationSubmissionResourceStateChanged({ type: 'LoadedResourceState', - // TODO: try to remove the cast - data: (await trustedAppsService.createTrustedApp(entry as PostTrustedAppCreateRequest)) - .data, + data: responseTrustedApp, }) ); store.dispatch({ @@ -371,7 +371,7 @@ const fetchEditTrustedAppIfNeeded = async ( dispatch({ type: 'trustedAppCreationDialogFormStateUpdated', payload: { - entry: toNewTrustedApp(trustedAppForEdit), + entry: toUpdateTrustedApp(trustedAppForEdit), isValid: true, }, }); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts index fea7d0d524701..79f3cf2220e8c 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts @@ -44,6 +44,7 @@ const generate = (count: number, generator: (i: number) => T) => export const createSampleTrustedApp = (i: number, longTexts?: boolean): TrustedApp => { return { id: String(i), + version: 'abc123', name: generate(longTexts ? 10 : 1, () => `trusted app ${i}`).join(' '), description: generate(longTexts ? 10 : 1, () => `Trusted App ${i}`).join(' '), created_at: '1 minute ago', diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts index 8689f80aa42a1..12fc21031aca5 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts @@ -59,7 +59,7 @@ export const OPERATOR_TITLE: { [K in ConditionEntry['operator']]: string } = { }; export const PROPERTY_TITLES: Readonly< - { [K in keyof Omit]: string } + { [K in keyof Omit]: string } > = { name: i18n.translate('xpack.securitySolution.trustedapps.trustedapp.name', { defaultMessage: 'Name', diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_notifications.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_notifications.tsx index 586c6a60d21a4..94fd1a2bb4991 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_notifications.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_notifications.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { memo } from 'react'; +import React, { memo, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { ServerApiError } from '../../../../common/types'; @@ -16,6 +16,7 @@ import { getDeletionError, isCreationSuccessful, isDeletionSuccessful, + isEdit, } from '../store/selectors'; import { useToasts } from '../../../../common/lib/kibana'; @@ -56,14 +57,27 @@ const getCreationSuccessMessage = (entry: Immutable) => { ); }; +const getUpdateSuccessMessage = (entry: Immutable) => { + return i18n.translate( + 'xpack.securitySolution.trustedapps.createTrustedAppFlyout.updateSuccessToastTitle', + { + defaultMessage: '"{name}" has been updated successfully', + values: { name: entry.name }, + } + ); +}; + export const TrustedAppsNotifications = memo(() => { const deletionError = useTrustedAppsSelector(getDeletionError); const deletionDialogEntry = useTrustedAppsSelector(getDeletionDialogEntry); const deletionSuccessful = useTrustedAppsSelector(isDeletionSuccessful); const creationDialogNewEntry = useTrustedAppsSelector(getCreationDialogFormEntry); const creationSuccessful = useTrustedAppsSelector(isCreationSuccessful); + const editMode = useTrustedAppsSelector(isEdit); const toasts = useToasts(); + const [wasAlreadyHandled] = useState(new WeakSet()); + if (deletionError && deletionDialogEntry) { toasts.addDanger(getDeletionErrorMessage(deletionError, deletionDialogEntry)); } @@ -72,8 +86,17 @@ export const TrustedAppsNotifications = memo(() => { toasts.addSuccess(getDeletionSuccessMessage(deletionDialogEntry)); } - if (creationSuccessful && creationDialogNewEntry) { - toasts.addSuccess(getCreationSuccessMessage(creationDialogNewEntry)); + if ( + creationSuccessful && + creationDialogNewEntry && + !wasAlreadyHandled.has(creationDialogNewEntry) + ) { + wasAlreadyHandled.add(creationDialogNewEntry); + + toasts.addSuccess( + (editMode && getUpdateSuccessMessage(creationDialogNewEntry)) || + getCreationSuccessMessage(creationDialogNewEntry) + ); } return <>; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx index b2586253be636..a075aff277a00 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx @@ -49,6 +49,7 @@ describe('When on the Trusted Apps Page', () => { const getFakeTrustedApp = (): TrustedApp => ({ id: '1111-2222-3333-4444', + version: 'abc123', name: 'one app', os: OperatingSystem.WINDOWS, created_at: '2021-01-04T13:55:00.561Z', @@ -178,12 +179,36 @@ describe('When on the Trusted Apps Page', () => { }); describe('and when Save is clicked', () => { - // Will be unskiped once PUT API is created - it.skip('should call the correct api (PUT)', () => { + it('should call the correct api (PUT)', () => { act(() => { fireEvent.click(renderResult.getByTestId('addTrustedAppFlyout-createButton')); }); - expect(coreStart.http.put).toHaveBeenCalledWith({}); + + expect(coreStart.http.put).toHaveBeenCalledTimes(1); + + const lastCallToPut = (coreStart.http.put.mock.calls[0] as unknown) as [ + string, + HttpFetchOptions + ]; + + expect(lastCallToPut[0]).toEqual('/api/endpoint/trusted_apps/1111-2222-3333-4444'); + expect(JSON.parse(lastCallToPut[1].body as string)).toEqual({ + name: 'one app', + os: 'windows', + entries: [ + { + field: 'process.executable.caseless', + value: 'one/two', + operator: 'included', + type: 'match', + }, + ], + description: 'a good one', + effectScope: { + type: 'global', + }, + version: 'abc123', + }); }); }); }); @@ -378,6 +403,7 @@ describe('When on the Trusted Apps Page', () => { data: { ...(JSON.parse(httpPostBody) as NewTrustedApp), id: '1', + version: 'abc123', created_at: '2020-09-16T14:09:45.484Z', created_by: 'kibana', }, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/errors.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/errors.ts new file mode 100644 index 0000000000000..5bb0a39bac348 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/errors.ts @@ -0,0 +1,20 @@ +/* + * 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. + */ + +/* eslint-disable max-classes-per-file */ + +export class TrustedAppNotFoundError extends Error { + constructor(id: string) { + super(`Trusted Application (${id}) not found`); + } +} + +export class TrustedAppVersionConflictError extends Error { + constructor(id: string, public sourceError: Error) { + super(`Trusted Application (${id}) has been updated since last retrieved`); + } +} diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts index ed546c2dac16e..143914fcc031e 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts @@ -27,37 +27,15 @@ import { getTrustedAppsDeleteRouteHandler, getTrustedAppsListRouteHandler, getTrustedAppsSummaryRouteHandler, + getTrustedAppsUpdateRouteHandler, } from './handlers'; import type { SecuritySolutionRequestHandlerContext } from '../../../types'; - -const exceptionsListClient = listMock.getExceptionListClient() as jest.Mocked; - -const createAppContextMock = () => ({ - logFactory: loggingSystemMock.create(), - service: new EndpointAppContextService(), - config: () => Promise.resolve(createMockConfig()), -}); - -const createHandlerContextMock = () => - (({ - ...xpackMocks.createRequestHandlerContext(), - lists: { - getListClient: jest.fn(), - getExceptionListClient: jest.fn().mockReturnValue(exceptionsListClient), - }, - } as unknown) as jest.Mocked); - -const assertResponse = ( - response: jest.Mocked, - expectedResponseType: keyof KibanaResponseFactory, - expectedResponseBody: T -) => { - expect(response[expectedResponseType]).toBeCalled(); - expect(response[expectedResponseType].mock.calls[0][0]?.body).toEqual(expectedResponseBody); -}; +import { TrustedAppNotFoundError, TrustedAppVersionConflictError } from './errors'; +import { updateExceptionListItemImplementationMock } from './test_utils'; +import { Logger } from '@kbn/logging'; const EXCEPTION_LIST_ITEM: ExceptionListItemSchema = { - _version: '123', + _version: 'abc123', id: '123', comments: [], created_at: '11/11/2011T11:11:11.111', @@ -93,6 +71,7 @@ const NEW_TRUSTED_APP: NewTrustedApp = { const TRUSTED_APP: TrustedApp = { id: '123', + version: 'abc123', created_at: '11/11/2011T11:11:11.111', created_by: 'admin', name: 'linux trusted app 1', @@ -106,20 +85,60 @@ const TRUSTED_APP: TrustedApp = { }; describe('handlers', () => { - const appContextMock = createAppContextMock(); + const createAppContextMock = () => { + const context = { + logFactory: loggingSystemMock.create(), + service: new EndpointAppContextService(), + config: () => Promise.resolve(createMockConfig()), + }; + + // Ensure that `logFactory.get()` always returns the same instance for the same given prefix + const instances = new Map>(); + const logFactoryGetMock = context.logFactory.get.getMockImplementation(); + context.logFactory.get.mockImplementation( + (prefix): Logger => { + if (!instances.has(prefix)) { + instances.set(prefix, logFactoryGetMock!(prefix)!); + } + return instances.get(prefix)!; + } + ); + + return context; + }; + + let appContextMock: ReturnType = createAppContextMock(); + let exceptionsListClient: jest.Mocked = listMock.getExceptionListClient() as jest.Mocked; + + const createHandlerContextMock = () => + (({ + ...xpackMocks.createRequestHandlerContext(), + lists: { + getListClient: jest.fn(), + getExceptionListClient: jest.fn().mockReturnValue(exceptionsListClient), + }, + } as unknown) as jest.Mocked); + + const assertResponse = ( + response: jest.Mocked, + expectedResponseType: keyof KibanaResponseFactory, + expectedResponseBody: T + ) => { + expect(response[expectedResponseType]).toBeCalled(); + expect(response[expectedResponseType].mock.calls[0][0]?.body).toEqual(expectedResponseBody); + }; beforeEach(() => { - exceptionsListClient.deleteExceptionListItem.mockReset(); - exceptionsListClient.createExceptionListItem.mockReset(); - exceptionsListClient.findExceptionListItem.mockReset(); - exceptionsListClient.createTrustedAppsList.mockReset(); - - appContextMock.logFactory.get.mockClear(); - (appContextMock.logFactory.get().error as jest.Mock).mockClear(); + appContextMock = createAppContextMock(); + exceptionsListClient = listMock.getExceptionListClient() as jest.Mocked; }); describe('getTrustedAppsDeleteRouteHandler', () => { - const deleteTrustedAppHandler = getTrustedAppsDeleteRouteHandler(); + let deleteTrustedAppHandler: ReturnType; + + beforeEach(() => { + deleteTrustedAppHandler = getTrustedAppsDeleteRouteHandler(appContextMock); + }); it('should return ok when trusted app deleted', async () => { const mockResponse = httpServerMock.createResponseFactory(); @@ -138,13 +157,15 @@ describe('handlers', () => { it('should return notFound when trusted app missing', async () => { const mockResponse = httpServerMock.createResponseFactory(); + exceptionsListClient.deleteExceptionListItem.mockResolvedValue(null); + await deleteTrustedAppHandler( createHandlerContextMock(), httpServerMock.createKibanaRequest({ params: { id: '123' } }), mockResponse ); - assertResponse(mockResponse, 'notFound', 'trusted app id [123] not found'); + assertResponse(mockResponse, 'notFound', new TrustedAppNotFoundError('123')); }); it('should return internalError when errors happen', async () => { @@ -164,7 +185,11 @@ describe('handlers', () => { }); describe('getTrustedAppsCreateRouteHandler', () => { - const createTrustedAppHandler = getTrustedAppsCreateRouteHandler(); + let createTrustedAppHandler: ReturnType; + + beforeEach(() => { + createTrustedAppHandler = getTrustedAppsCreateRouteHandler(appContextMock); + }); it('should return ok with body when trusted app created', async () => { const mockResponse = httpServerMock.createResponseFactory(); @@ -197,7 +222,11 @@ describe('handlers', () => { }); describe('getTrustedAppsListRouteHandler', () => { - const getTrustedAppsListHandler = getTrustedAppsListRouteHandler(); + let getTrustedAppsListHandler: ReturnType; + + beforeEach(() => { + getTrustedAppsListHandler = getTrustedAppsListRouteHandler(appContextMock); + }); it('should return ok with list when no errors', async () => { const mockResponse = httpServerMock.createResponseFactory(); @@ -240,7 +269,11 @@ describe('handlers', () => { }); describe('getTrustedAppsSummaryHandler', () => { - const getTrustedAppsSummaryHandler = getTrustedAppsSummaryRouteHandler(); + let getTrustedAppsSummaryHandler: ReturnType; + + beforeEach(() => { + getTrustedAppsSummaryHandler = getTrustedAppsSummaryRouteHandler(appContextMock); + }); it('should return ok with list when no errors', async () => { const mockResponse = httpServerMock.createResponseFactory(); @@ -303,4 +336,89 @@ describe('handlers', () => { ).rejects.toThrowError(error); }); }); + + describe('getTrustedAppsUpdateRouteHandler', () => { + let updateHandler: ReturnType; + let mockResponse: ReturnType; + + beforeEach(() => { + updateHandler = getTrustedAppsUpdateRouteHandler(appContextMock); + mockResponse = httpServerMock.createResponseFactory(); + }); + + it('should return success with updated trusted app', async () => { + exceptionsListClient.getExceptionListItem.mockResolvedValue(EXCEPTION_LIST_ITEM); + exceptionsListClient.updateExceptionListItem.mockImplementationOnce( + updateExceptionListItemImplementationMock + ); + + await updateHandler( + createHandlerContextMock(), + httpServerMock.createKibanaRequest({ params: { id: '123' }, body: NEW_TRUSTED_APP }), + mockResponse + ); + + expect(mockResponse.ok).toHaveBeenCalledWith({ + body: { + data: { + created_at: '11/11/2011T11:11:11.111', + created_by: 'admin', + description: 'Linux trusted app 1', + effectScope: { + type: 'global', + }, + entries: [ + { + field: 'process.hash.*', + operator: 'included', + type: 'match', + value: '1234234659af249ddf3e40864e9fb241', + }, + { + field: 'process.executable.caseless', + operator: 'included', + type: 'match', + value: '/bin/malware', + }, + ], + id: '123', + name: 'linux trusted app 1', + os: 'linux', + version: 'abc123', + }, + }, + }); + }); + + it('should return 404 if trusted app does not exist', async () => { + exceptionsListClient.getExceptionListItem.mockResolvedValueOnce(null); + + await updateHandler( + createHandlerContextMock(), + httpServerMock.createKibanaRequest({ params: { id: '123' }, body: NEW_TRUSTED_APP }), + mockResponse + ); + + expect(mockResponse.notFound).toHaveBeenCalledWith({ + body: expect.any(TrustedAppNotFoundError), + }); + }); + + it('should should return 409 if version conflict occurs', async () => { + exceptionsListClient.getExceptionListItem.mockResolvedValue(EXCEPTION_LIST_ITEM); + exceptionsListClient.updateExceptionListItem.mockRejectedValue( + Object.assign(new Error(), { output: { statusCode: 409 } }) + ); + + await updateHandler( + createHandlerContextMock(), + httpServerMock.createKibanaRequest({ params: { id: '123' }, body: NEW_TRUSTED_APP }), + mockResponse + ); + + expect(mockResponse.conflict).toHaveBeenCalledWith({ + body: expect.any(TrustedAppVersionConflictError), + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.ts index fd5160472986f..53d57a33897d8 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { RequestHandler } from 'kibana/server'; +import type { KibanaResponseFactory, RequestHandler, IKibanaResponse, Logger } from 'kibana/server'; import type { SecuritySolutionRequestHandlerContext } from '../../../types'; import { ExceptionListClient } from '../../../../../lists/server'; @@ -14,6 +14,8 @@ import { DeleteTrustedAppsRequestParams, GetTrustedAppsListRequest, PostTrustedAppCreateRequest, + PutTrustedAppsRequestParams, + PutTrustedAppUpdateRequest, } from '../../../../common/endpoint/types'; import { @@ -21,8 +23,9 @@ import { deleteTrustedApp, getTrustedAppsList, getTrustedAppsSummary, - MissingTrustedAppException, + updateTrustedApp, } from './service'; +import { TrustedAppNotFoundError, TrustedAppVersionConflictError } from './errors'; const exceptionListClientFromContext = ( context: SecuritySolutionRequestHandlerContext @@ -36,7 +39,27 @@ const exceptionListClientFromContext = ( return exceptionLists; }; -export const getTrustedAppsDeleteRouteHandler = (): RequestHandler< +const errorHandler = ( + logger: Logger, + res: KibanaResponseFactory, + error: E +): IKibanaResponse => { + logger.error(error); + + if (error instanceof TrustedAppNotFoundError) { + return res.notFound({ body: error }); + } + + if (error instanceof TrustedAppVersionConflictError) { + return res.conflict({ body: error }); + } + + return res.internalError({ body: error }); +}; + +export const getTrustedAppsDeleteRouteHandler = ( + endpointAppContext: EndpointAppContext +): RequestHandler< DeleteTrustedAppsRequestParams, unknown, unknown, @@ -48,11 +71,7 @@ export const getTrustedAppsDeleteRouteHandler = (): RequestHandler< return res.ok(); } catch (error) { - if (error instanceof MissingTrustedAppException) { - return res.notFound({ body: `trusted app id [${req.params.id}] not found` }); - } else { - throw error; - } + return errorHandler(logger, res, error); } }; }; @@ -64,9 +83,13 @@ export const getTrustedAppsListRouteHandler = (): RequestHandler< SecuritySolutionRequestHandlerContext > => { return async (context, req, res) => { - return res.ok({ - body: await getTrustedAppsList(exceptionListClientFromContext(context), req.query), - }); + try { + return res.ok({ + body: await getTrustedAppsList(exceptionListClientFromContext(context), req.query), + }); + } catch (error) { + return errorHandler(logger, res, error); + } }; }; @@ -77,21 +100,51 @@ export const getTrustedAppsCreateRouteHandler = (): RequestHandler< SecuritySolutionRequestHandlerContext > => { return async (context, req, res) => { - return res.ok({ - body: await createTrustedApp(exceptionListClientFromContext(context), req.body), - }); + try { + return res.ok({ + body: await createTrustedApp(exceptionListClientFromContext(context), req.body), + }); + } catch (error) { + return errorHandler(logger, res, error); + } }; }; -export const getTrustedAppsSummaryRouteHandler = (): RequestHandler< +export const getTrustedAppsUpdateRouteHandler = ( + endpointAppContext: EndpointAppContext +): RequestHandler< + PutTrustedAppsRequestParams, unknown, - unknown, - PostTrustedAppCreateRequest, + PutTrustedAppUpdateRequest, SecuritySolutionRequestHandlerContext > => { return async (context, req, res) => { - return res.ok({ - body: await getTrustedAppsSummary(exceptionListClientFromContext(context)), - }); + try { + return res.ok({ + body: await updateTrustedApp( + exceptionListClientFromContext(context), + req.params.id, + req.body + ), + }); + } catch (error) { + return errorHandler(logger, res, error); + } + }; +}; + +export const getTrustedAppsSummaryRouteHandler = ( + endpointAppContext: EndpointAppContext +): RequestHandler => { + const logger = endpointAppContext.logFactory.get('trusted_apps'); + + return async (context, req, res) => { + try { + return res.ok({ + body: await getTrustedAppsSummary(exceptionListClientFromContext(context)), + }); + } catch (error) { + return errorHandler(logger, res, error); + } }; }; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/index.ts index 4a17b088dc871..5dac66046ffc8 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/index.ts @@ -9,11 +9,13 @@ import { DeleteTrustedAppsRequestSchema, GetTrustedAppsRequestSchema, PostTrustedAppCreateRequestSchema, + PutTrustedAppUpdateRequestSchema, } from '../../../../common/endpoint/schema/trusted_apps'; import { TRUSTED_APPS_CREATE_API, TRUSTED_APPS_DELETE_API, TRUSTED_APPS_LIST_API, + TRUSTED_APPS_UPDATE_API, TRUSTED_APPS_SUMMARY_API, } from '../../../../common/endpoint/constants'; import { @@ -21,6 +23,7 @@ import { getTrustedAppsDeleteRouteHandler, getTrustedAppsListRouteHandler, getTrustedAppsSummaryRouteHandler, + getTrustedAppsUpdateRouteHandler, } from './handlers'; import { SecuritySolutionPluginRouter } from '../../../types'; @@ -55,6 +58,16 @@ export const registerTrustedAppsRoutes = (router: SecuritySolutionPluginRouter) getTrustedAppsCreateRouteHandler() ); + // PUT + router.put( + { + path: TRUSTED_APPS_UPDATE_API, + validate: PutTrustedAppUpdateRequestSchema, + options: { authRequired: true }, + }, + getTrustedAppsUpdateRouteHandler(endpointAppContext) + ); + // SUMMARY router.get( { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.test.ts index 3b47ca7fa68f8..91e3ed870d8c1 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.test.ts @@ -13,6 +13,7 @@ import { NewTrustedApp, OperatingSystem, TrustedApp, + UpdateTrustedApp, } from '../../../../common/endpoint/types'; import { @@ -21,6 +22,7 @@ import { createEntryNested, exceptionListItemToTrustedApp, newTrustedAppToCreateExceptionListItemOptions, + updatedTrustedAppToUpdateExceptionListItemOptions, } from './mapping'; const createExceptionListItemOptions = ( @@ -43,7 +45,7 @@ const createExceptionListItemOptions = ( const exceptionListItemSchema = ( item: Partial ): ExceptionListItemSchema => ({ - _version: '123', + _version: 'abc123', id: '', comments: [], created_at: '', @@ -259,6 +261,7 @@ describe('mapping', () => { }), { id: '123', + version: 'abc123', name: 'linux trusted app', description: 'Linux Trusted App', effectScope: { type: 'global' }, @@ -283,6 +286,7 @@ describe('mapping', () => { }), { id: '123', + version: 'abc123', name: 'macos trusted app', description: 'MacOS Trusted App', effectScope: { type: 'global' }, @@ -307,6 +311,7 @@ describe('mapping', () => { }), { id: '123', + version: 'abc123', name: 'windows trusted app', description: 'Windows Trusted App', effectScope: { type: 'global' }, @@ -336,6 +341,7 @@ describe('mapping', () => { }), { id: '123', + version: 'abc123', name: 'signed trusted app', description: 'Signed trusted app', effectScope: { type: 'global' }, @@ -360,6 +366,7 @@ describe('mapping', () => { }), { id: '123', + version: 'abc123', name: 'MD5 trusted app', description: 'MD5 Trusted App', effectScope: { type: 'global' }, @@ -388,6 +395,7 @@ describe('mapping', () => { }), { id: '123', + version: 'abc123', name: 'SHA1 trusted app', description: 'SHA1 Trusted App', effectScope: { type: 'global' }, @@ -422,6 +430,7 @@ describe('mapping', () => { }), { id: '123', + version: 'abc123', name: 'SHA256 trusted app', description: 'SHA256 Trusted App', effectScope: { type: 'global' }, @@ -438,4 +447,43 @@ describe('mapping', () => { ); }); }); + + describe('updatedTrustedAppToUpdateExceptionListItemOptions', () => { + it('should map to UpdateExceptionListItemOptions', () => { + const updatedTrustedApp: UpdateTrustedApp = { + name: 'Linux trusted app', + description: 'Linux Trusted App', + effectScope: { type: 'global' }, + os: OperatingSystem.LINUX, + entries: [createConditionEntry(ConditionEntryField.PATH, '/bin/malware')], + version: 'abc', + }; + + expect( + updatedTrustedAppToUpdateExceptionListItemOptions( + exceptionListItemSchema({ id: 'original-id-here', item_id: 'original-item-id-here' }), + updatedTrustedApp + ) + ).toEqual({ + _version: 'abc', + comments: [], + description: 'Linux Trusted App', + entries: [ + { + field: 'process.executable.caseless', + operator: 'included', + type: 'match', + value: '/bin/malware', + }, + ], + id: 'original-id-here', + itemId: 'original-item-id-here', + name: 'Linux trusted app', + namespaceType: 'agnostic', + osTypes: ['linux'], + tags: ['policy:all'], + type: 'simple', + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.ts index 6b088e7635b45..eeda74fb9c5bb 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.ts @@ -7,16 +7,19 @@ import uuid from 'uuid'; -import { OsType } from '../../../../../lists/common/schemas/common'; +import { OsType } from '../../../../../lists/common/schemas'; import { EntriesArray, EntryMatch, EntryNested, ExceptionListItemSchema, NestedEntriesArray, -} from '../../../../../lists/common/shared_exports'; -import { ENDPOINT_TRUSTED_APPS_LIST_ID } from '../../../../../lists/common'; -import { CreateExceptionListItemOptions } from '../../../../../lists/server'; +} from '../../../../../lists/common'; +import { ENDPOINT_TRUSTED_APPS_LIST_ID } from '../../../../../lists/common/constants'; +import { + CreateExceptionListItemOptions, + UpdateExceptionListItemOptions, +} from '../../../../../lists/server'; import { ConditionEntry, ConditionEntryField, @@ -24,6 +27,7 @@ import { NewTrustedApp, OperatingSystem, TrustedApp, + UpdateTrustedApp, } from '../../../../common/endpoint/types'; type ConditionEntriesMap = { [K in ConditionEntryField]?: ConditionEntry }; @@ -114,6 +118,7 @@ export const exceptionListItemToTrustedApp = ( return { id: exceptionListItem.id, + version: exceptionListItem._version || '', name: exceptionListItem.name, description: exceptionListItem.description, effectScope: tagsToEffectScope(exceptionListItem.tags), @@ -216,3 +221,38 @@ export const newTrustedAppToCreateExceptionListItemOptions = ({ type: 'simple', }; }; + +/** + * Map UpdateTrustedApp to UpdateExceptionListItemOptions + * + * @param {ExceptionListItemSchema} currentTrustedAppExceptionItem + * @param {UpdateTrustedApp} updatedTrustedApp + */ +export const updatedTrustedAppToUpdateExceptionListItemOptions = ( + { + id, + item_id: itemId, + namespace_type: namespaceType, + type, + comments, + meta, + }: ExceptionListItemSchema, + { os, entries, name, description = '', effectScope, version }: UpdateTrustedApp +): UpdateExceptionListItemOptions => { + return { + _version: version, + name, + description, + entries: conditionEntriesToEntries(entries), + osTypes: [OPERATING_SYSTEM_TO_OS_TYPE[os]], + tags: effectScopeToTags(effectScope), + + // Copied from current trusted app exception item + id, + comments, + itemId, + meta, + namespaceType, + type, + }; +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.test.ts index bce2e4e28ab28..04a8c731d2407 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.test.ts @@ -19,13 +19,16 @@ import { deleteTrustedApp, getTrustedAppsList, getTrustedAppsSummary, - MissingTrustedAppException, + updateTrustedApp, } from './service'; +import { TrustedAppNotFoundError, TrustedAppVersionConflictError } from './errors'; +import { toUpdateTrustedApp } from '../../../../common/endpoint/service/trusted_apps/to_update_trusted_app'; +import { updateExceptionListItemImplementationMock } from './test_utils'; const exceptionsListClient = listMock.getExceptionListClient() as jest.Mocked; const EXCEPTION_LIST_ITEM: ExceptionListItemSchema = { - _version: '123', + _version: 'abc123', id: '123', comments: [], created_at: '11/11/2011T11:11:11.111', @@ -50,6 +53,7 @@ const EXCEPTION_LIST_ITEM: ExceptionListItemSchema = { const TRUSTED_APP: TrustedApp = { id: '123', + version: 'abc123', created_at: '11/11/2011T11:11:11.111', created_by: 'admin', name: 'linux trusted app 1', @@ -86,7 +90,7 @@ describe('service', () => { exceptionsListClient.deleteExceptionListItem.mockResolvedValue(null); await expect(deleteTrustedApp(exceptionsListClient, { id: '123' })).rejects.toBeInstanceOf( - MissingTrustedAppException + TrustedAppNotFoundError ); }); }); @@ -176,4 +180,79 @@ describe('service', () => { }); }); }); + + describe('updateTrustedApp', () => { + beforeEach(() => { + exceptionsListClient.getExceptionListItem.mockResolvedValue(EXCEPTION_LIST_ITEM); + + exceptionsListClient.updateExceptionListItem.mockImplementationOnce( + updateExceptionListItemImplementationMock + ); + }); + + afterEach(() => jest.resetAllMocks()); + + it('should update exception item with trusted app data', async () => { + const trustedAppForUpdate = toUpdateTrustedApp(TRUSTED_APP); + trustedAppForUpdate.name = 'updated name'; + trustedAppForUpdate.description = 'updated description'; + trustedAppForUpdate.entries = [trustedAppForUpdate.entries[0]]; + + await expect( + updateTrustedApp(exceptionsListClient, TRUSTED_APP.id, trustedAppForUpdate) + ).resolves.toEqual({ + data: { + created_at: '11/11/2011T11:11:11.111', + created_by: 'admin', + description: 'updated description', + effectScope: { + type: 'global', + }, + entries: [ + { + field: 'process.hash.*', + operator: 'included', + type: 'match', + value: '1234234659af249ddf3e40864e9fb241', + }, + ], + id: '123', + name: 'updated name', + os: 'linux', + version: 'abc123', + }, + }); + }); + + it('should throw a Not Found error if trusted app is not found prior to making update', async () => { + exceptionsListClient.getExceptionListItem.mockResolvedValueOnce(null); + await expect( + updateTrustedApp(exceptionsListClient, TRUSTED_APP.id, toUpdateTrustedApp(TRUSTED_APP)) + ).rejects.toBeInstanceOf(TrustedAppNotFoundError); + }); + + it('should throw a Version Conflict error if update fails with 409', async () => { + exceptionsListClient.updateExceptionListItem.mockReset(); + exceptionsListClient.updateExceptionListItem.mockRejectedValueOnce( + Object.assign(new Error('conflict'), { output: { statusCode: 409 } }) + ); + + await expect( + updateTrustedApp(exceptionsListClient, TRUSTED_APP.id, toUpdateTrustedApp(TRUSTED_APP)) + ).rejects.toBeInstanceOf(TrustedAppVersionConflictError); + }); + + it('should throw Not Found if exception item is not found during update', async () => { + exceptionsListClient.updateExceptionListItem.mockReset(); + exceptionsListClient.updateExceptionListItem.mockResolvedValueOnce(null); + + exceptionsListClient.getExceptionListItem.mockReset(); + exceptionsListClient.getExceptionListItem.mockResolvedValueOnce(EXCEPTION_LIST_ITEM); + exceptionsListClient.getExceptionListItem.mockResolvedValueOnce(null); + + await expect( + updateTrustedApp(exceptionsListClient, TRUSTED_APP.id, toUpdateTrustedApp(TRUSTED_APP)) + ).rejects.toBeInstanceOf(TrustedAppNotFoundError); + }); + }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.ts index 97a8451bf25d8..c54f2994ee0f0 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.ts @@ -15,17 +15,18 @@ import { GetTrustedListAppsResponse, PostTrustedAppCreateRequest, PostTrustedAppCreateResponse, + PutTrustedAppUpdateRequest, + PutTrustedAppUpdateResponse, } from '../../../../common/endpoint/types'; import { exceptionListItemToTrustedApp, newTrustedAppToCreateExceptionListItemOptions, osFromExceptionItem, + updatedTrustedAppToUpdateExceptionListItemOptions, } from './mapping'; - -export class MissingTrustedAppException { - constructor(public id: string) {} -} +import { ExceptionListItemSchema } from '../../../../../lists/common'; +import { TrustedAppNotFoundError, TrustedAppVersionConflictError } from './errors'; export const deleteTrustedApp = async ( exceptionsListClient: ExceptionListClient, @@ -38,7 +39,7 @@ export const deleteTrustedApp = async ( }); if (!exceptionListItem) { - throw new MissingTrustedAppException(id); + throw new TrustedAppNotFoundError(id); } }; @@ -74,6 +75,9 @@ export const createTrustedApp = async ( // Ensure list is created if it does not exist await exceptionsListClient.createTrustedAppsList(); + // Validate update TA entry - error if not valid + // TODO: implement validations + const createdTrustedAppExceptionItem = await exceptionsListClient.createExceptionListItem( newTrustedAppToCreateExceptionListItemOptions(newTrustedApp) ); @@ -81,6 +85,48 @@ export const createTrustedApp = async ( return { data: exceptionListItemToTrustedApp(createdTrustedAppExceptionItem) }; }; +export const updateTrustedApp = async ( + exceptionsListClient: ExceptionListClient, + id: string, + updatedTrustedApp: PutTrustedAppUpdateRequest +): Promise => { + const currentTrustedApp = await exceptionsListClient.getExceptionListItem({ + itemId: '', + id, + namespaceType: 'agnostic', + }); + + if (!currentTrustedApp) { + throw new TrustedAppNotFoundError(id); + } + + // Validate update TA entry - error if not valid + // TODO: implement validations + + let updatedTrustedAppExceptionItem: ExceptionListItemSchema | null; + + try { + updatedTrustedAppExceptionItem = await exceptionsListClient.updateExceptionListItem( + updatedTrustedAppToUpdateExceptionListItemOptions(currentTrustedApp, updatedTrustedApp) + ); + } catch (e) { + if (e?.output?.statusCode === 409) { + throw new TrustedAppVersionConflictError(id, e); + } + + throw e; + } + + // If `null` is returned, then that means the TA does not exist (could happen in race conditions) + if (!updatedTrustedAppExceptionItem) { + throw new TrustedAppNotFoundError(id); + } + + return { + data: exceptionListItemToTrustedApp(updatedTrustedAppExceptionItem), + }; +}; + export const getTrustedAppsSummary = async ( exceptionsListClient: ExceptionListClient ): Promise => { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/test_utils.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/test_utils.ts new file mode 100644 index 0000000000000..2aee9f22887a9 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/test_utils.ts @@ -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 { ExceptionListClient } from '../../../../../lists/server'; + +export const updateExceptionListItemImplementationMock: ExceptionListClient['updateExceptionListItem'] = async ( + listItem +) => { + return { + _version: listItem._version || 'abc123', + id: listItem.id || '123', + comments: [], + created_at: '11/11/2011T11:11:11.111', + created_by: 'admin', + description: listItem.description || '', + entries: listItem.entries || [], + item_id: listItem.itemId || '', + list_id: 'endpoint_trusted_apps', + meta: undefined, + name: listItem.name || '', + namespace_type: listItem.namespaceType || '', + os_types: listItem.osTypes || '', + tags: listItem.tags || [], + type: 'simple', + tie_breaker_id: '123', + updated_at: '11/11/2011T11:11:11.111', + updated_by: 'admin', + }; +}; From a894195d637a9a13e44fd9d8d48ae394b917bc1d Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Tue, 16 Feb 2021 15:48:00 -0500 Subject: [PATCH 07/31] [SECURITY SOLUTION][ENDPOINT] Trusted Apps API to retrieve a single Trusted App item (#90842) * Get One Trusted App API - route, service, handler * Adjust UI to call GET api to retrieve trusted app for edit * Deleted ununsed trusted app types file * Add UI handling of non-existing TA for edit or when id is missing in url --- .../common/endpoint/constants.ts | 1 + .../common/endpoint/schema/trusted_apps.ts | 6 + .../common/endpoint/types/trusted_apps.ts | 7 ++ .../pages/trusted_apps/service/index.ts | 10 ++ .../trusted_apps/store/middleware.test.ts | 1 + .../pages/trusted_apps/store/middleware.ts | 64 ++++++++-- .../pages/trusted_apps/store/selectors.ts | 6 + .../management/pages/trusted_apps/types.ts | 12 -- .../components/create_trusted_app_flyout.tsx | 37 ++++++ .../view/trusted_apps_page.test.tsx | 119 +++++++++++++++++- .../routes/trusted_apps/handlers.test.ts | 55 ++++++++ .../endpoint/routes/trusted_apps/handlers.ts | 27 +++- .../endpoint/routes/trusted_apps/index.ts | 13 ++ .../routes/trusted_apps/service.test.ts | 15 +++ .../endpoint/routes/trusted_apps/service.ts | 20 +++ 15 files changed, 365 insertions(+), 28 deletions(-) delete mode 100644 x-pack/plugins/security_solution/public/management/pages/trusted_apps/types.ts diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index 3f63ce1b10dfa..d9f67e31196ca 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -15,6 +15,7 @@ export const telemetryIndexPattern = 'metrics-endpoint.telemetry-*'; export const LIMITED_CONCURRENCY_ENDPOINT_ROUTE_TAG = 'endpoint:limited-concurrency'; export const LIMITED_CONCURRENCY_ENDPOINT_COUNT = 100; +export const TRUSTED_APPS_GET_API = '/api/endpoint/trusted_apps/{id}'; export const TRUSTED_APPS_LIST_API = '/api/endpoint/trusted_apps'; export const TRUSTED_APPS_CREATE_API = '/api/endpoint/trusted_apps'; export const TRUSTED_APPS_UPDATE_API = '/api/endpoint/trusted_apps/{id}'; diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts index bdc3660629366..a89b584cca8b6 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts @@ -15,6 +15,12 @@ export const DeleteTrustedAppsRequestSchema = { }), }; +export const GetOneTrustedAppRequestSchema = { + params: schema.object({ + id: schema.string(), + }), +}; + export const GetTrustedAppsRequestSchema = { query: schema.object({ page: schema.maybe(schema.number({ defaultValue: 1, min: 1 })), diff --git a/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts index ce55d9e5d7e7a..f5c55b13e7016 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts @@ -9,6 +9,7 @@ import { TypeOf } from '@kbn/config-schema'; import { ApplicationStart } from 'kibana/public'; import { DeleteTrustedAppsRequestSchema, + GetOneTrustedAppRequestSchema, GetTrustedAppsRequestSchema, PostTrustedAppCreateRequestSchema, PutTrustedAppUpdateRequestSchema, @@ -18,6 +19,12 @@ import { OperatingSystem } from './os'; /** API request params for deleting Trusted App entry */ export type DeleteTrustedAppsRequestParams = TypeOf; +export type GetOneTrustedAppRequestParams = TypeOf; + +export interface GetOneTrustedAppResponse { + data: TrustedApp; +} + /** API request params for retrieving a list of Trusted Apps */ export type GetTrustedAppsListRequest = TypeOf; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts index 5c43ff989594f..5f572251daeda 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts @@ -10,6 +10,7 @@ import { HttpStart } from 'kibana/public'; import { TRUSTED_APPS_CREATE_API, TRUSTED_APPS_DELETE_API, + TRUSTED_APPS_GET_API, TRUSTED_APPS_LIST_API, TRUSTED_APPS_UPDATE_API, TRUSTED_APPS_SUMMARY_API, @@ -25,12 +26,15 @@ import { PutTrustedAppUpdateRequest, PutTrustedAppUpdateResponse, PutTrustedAppsRequestParams, + GetOneTrustedAppRequestParams, + GetOneTrustedAppResponse, } from '../../../../../common/endpoint/types/trusted_apps'; import { resolvePathVariables } from './utils'; import { sendGetEndpointSpecificPackagePolicies } from '../../policy/store/services/ingest'; export interface TrustedAppsService { + getTrustedApp(params: GetOneTrustedAppRequestParams): Promise; getTrustedAppsList(request: GetTrustedAppsListRequest): Promise; deleteTrustedApp(request: DeleteTrustedAppsRequestParams): Promise; createTrustedApp(request: PostTrustedAppCreateRequest): Promise; @@ -46,6 +50,12 @@ export interface TrustedAppsService { export class TrustedAppsHttpService implements TrustedAppsService { constructor(private http: HttpStart) {} + async getTrustedApp(params: GetOneTrustedAppRequestParams) { + return this.http.get( + resolvePathVariables(TRUSTED_APPS_GET_API, params) + ); + } + async getTrustedAppsList(request: GetTrustedAppsListRequest) { return this.http.get(TRUSTED_APPS_LIST_API, { query: request, diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts index 735f64e09a183..ed45d077dd0ca 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts @@ -52,6 +52,7 @@ const createTrustedAppsServiceMock = (): jest.Mocked => ({ createTrustedApp: jest.fn(), getPolicyList: jest.fn(), updateTrustedApp: jest.fn(), + getTrustedApp: jest.fn(), }); const createStoreSetup = (trustedAppsService: TrustedAppsService) => { diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts index 976401f6f28db..7f940f14f9c6c 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { i18n } from '@kbn/i18n'; import { Immutable, PostTrustedAppCreateRequest, @@ -60,6 +61,7 @@ import { editItemId, editingTrustedApp, getListItems, + editItemState, } from './selectors'; import { toUpdateTrustedApp } from '../../../../../common/endpoint/service/trusted_apps/to_update_trusted_app'; @@ -331,7 +333,7 @@ export const retrieveListOfPoliciesIfNeeded = async ( type: 'trustedAppsPoliciesStateChanged', payload: { type: 'FailedResourceState', - error: error.body, + error: error.body || error, lastLoadedState: getLastLoadedResourceState(policiesState(getState())), }, }); @@ -349,7 +351,25 @@ const fetchEditTrustedAppIfNeeded = async ( const isAlreadyFetching = isFetchingEditTrustedAppItem(currentState); const editTrustedAppId = editItemId(currentState); - if (isPageActive && isEditFlow && editTrustedAppId && !isAlreadyFetching) { + if (isPageActive && isEditFlow && !isAlreadyFetching) { + if (!editTrustedAppId) { + const errorMessage = i18n.translate( + 'xpack.securitySolution.trustedapps.middleware.editIdMissing', + { + defaultMessage: 'No id provided', + } + ); + + dispatch({ + type: 'trustedAppCreationEditItemStateChanged', + payload: { + type: 'FailedResourceState', + error: Object.assign(new Error(errorMessage), { statusCode: 404, error: errorMessage }), + }, + }); + return; + } + let trustedAppForEdit = editingTrustedApp(currentState); // If Trusted App is already loaded, then do nothing @@ -359,7 +379,27 @@ const fetchEditTrustedAppIfNeeded = async ( // See if we can get the Trusted App record from the current list of Trusted Apps being displayed trustedAppForEdit = getListItems(currentState).find((ta) => ta.id === editTrustedAppId); - if (trustedAppForEdit) { + + try { + // Retrieve Trusted App record via API if it was not in the list data. + // This would be the case when linking from another place or using an UUID for a Trusted App + // that is not currently displayed on the list view. + if (!trustedAppForEdit) { + dispatch({ + type: 'trustedAppCreationEditItemStateChanged', + payload: { + type: 'LoadingResourceState', + // No easy way to get around this that I can see. `previousState` does not + // seem to allow everything that `editItem` state can hold, so not even sure if using + // type guards would work here + // @ts-ignore + previousState: editItemState(currentState)!, + }, + }); + + trustedAppForEdit = (await trustedAppsService.getTrustedApp({ id: editTrustedAppId })).data; + } + dispatch({ type: 'trustedAppCreationEditItemStateChanged', payload: { @@ -375,17 +415,15 @@ const fetchEditTrustedAppIfNeeded = async ( isValid: true, }, }); - return; + } catch (e) { + dispatch({ + type: 'trustedAppCreationEditItemStateChanged', + payload: { + type: 'FailedResourceState', + error: e, + }, + }); } - - // Retrieve Trusted App record via API. This would be the case when linking from another place or - // using an UUID for a Trusted App that is not currently displayed on the list view. - - // eslint-disable-next-line no-console - console.log('todo: api call'); - - // FIXME: Implement GET API - throw new Error('GET trusted app API missing!'); } }; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts index 65bb0abe4be46..7c131c3eaa7a9 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts @@ -226,6 +226,12 @@ export const isFetchingEditTrustedAppItem: ( return editTrustedAppState ? isLoadingResourceState(editTrustedAppState) : false; }); +export const editTrustedAppFetchError: ( + state: Immutable +) => ServerApiError | undefined = createSelector(editItemState, (itemForEditState) => { + return itemForEditState && getCurrentResourceError(itemForEditState); +}); + export const editingTrustedApp: ( state: Immutable ) => undefined | Immutable = createSelector(editItemState, (editTrustedAppState) => { diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/types.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/types.ts deleted file mode 100644 index 0e57470d3de05..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* - * 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. - */ - -export interface TrustedAppsUrlParams { - page_index: number; - page_size: number; - show?: 'create'; -} diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_flyout.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_flyout.tsx index 369553555f5ea..8a0d60275bcfe 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_flyout.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_flyout.tsx @@ -22,10 +22,14 @@ import React, { memo, useCallback, useEffect, useMemo } from 'react'; import { EuiFlyoutProps } from '@elastic/eui/src/components/flyout/flyout'; import { FormattedMessage } from '@kbn/i18n/react'; import { useDispatch } from 'react-redux'; +import { useHistory } from 'react-router-dom'; +import { i18n } from '@kbn/i18n'; import { CreateTrustedAppForm, CreateTrustedAppFormProps } from './create_trusted_app_form'; import { + editTrustedAppFetchError, getCreationDialogFormEntry, getCreationError, + getCurrentLocation, isCreationDialogFormValid, isCreationInProgress, isCreationSuccessful, @@ -38,11 +42,15 @@ import { useTrustedAppsSelector } from '../hooks'; import { ABOUT_TRUSTED_APPS, CREATE_TRUSTED_APP_ERROR } from '../translations'; import { defaultNewTrustedApp } from '../../store/builders'; +import { getTrustedAppsListPath } from '../../../../common/routing'; +import { useToasts } from '../../../../../common/lib/kibana'; type CreateTrustedAppFlyoutProps = Omit; export const CreateTrustedAppFlyout = memo( ({ onClose, ...flyoutProps }) => { const dispatch = useDispatch<(action: AppAction) => void>(); + const history = useHistory(); + const toasts = useToasts(); const creationInProgress = useTrustedAppsSelector(isCreationInProgress); const creationErrors = useTrustedAppsSelector(getCreationError); @@ -51,7 +59,9 @@ export const CreateTrustedAppFlyout = memo( const isLoadingPolicies = useTrustedAppsSelector(loadingPolicies); const policyList = useTrustedAppsSelector(listOfPolicies); const isEditMode = useTrustedAppsSelector(isEdit); + const trustedAppFetchError = useTrustedAppsSelector(editTrustedAppFetchError); const formValues = useTrustedAppsSelector(getCreationDialogFormEntry) || defaultNewTrustedApp(); + const location = useTrustedAppsSelector(getCurrentLocation); const dataTestSubj = flyoutProps['data-test-subj']; @@ -102,6 +112,33 @@ export const CreateTrustedAppFlyout = memo( [dispatch] ); + // If there was a failure trying to retrieve the Trusted App for edit item, + // then redirect back to the list ++ show toast message. + useEffect(() => { + if (trustedAppFetchError) { + // Replace the current URL route so that user does not keep hitting this page via browser back/fwd buttons + history.replace( + getTrustedAppsListPath({ + ...location, + show: undefined, + id: undefined, + }) + ); + + toasts.addWarning( + i18n.translate( + 'xpack.securitySolution.trustedapps.createTrustedAppFlyout.notFoundToastMessage', + { + defaultMessage: 'Unable to edit trusted application ({apiMsg})', + values: { + apiMsg: trustedAppFetchError.message, + }, + } + ) + ); + } + }, [history, location, toasts, trustedAppFetchError]); + // If it was created, then close flyout useEffect(() => { if (creationSuccessful) { diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx index a075aff277a00..497ef50855c6a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx @@ -20,14 +20,18 @@ import { TrustedApp, } from '../../../../../common/endpoint/types'; import { HttpFetchOptions } from 'kibana/public'; -import { TRUSTED_APPS_LIST_API } from '../../../../../common/endpoint/constants'; +import { + TRUSTED_APPS_GET_API, + TRUSTED_APPS_LIST_API, +} from '../../../../../common/endpoint/constants'; import { GetPackagePoliciesResponse, PACKAGE_POLICY_API_ROUTES, } from '../../../../../../fleet/common'; import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_data'; -import { isLoadedResourceState } from '../state'; +import { isFailedResourceState, isLoadedResourceState } from '../state'; import { forceHTMLElementOffsetWidth } from './components/effected_policy_select/test_utils'; +import { resolvePathVariables } from '../service/utils'; jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ htmlIdGenerator: () => () => 'mockId', @@ -158,6 +162,10 @@ describe('When on the Trusted Apps Page', () => { }); }); + it('should persist edit params to url', () => { + expect(history.location.search).toEqual('?show=edit&id=1111-2222-3333-4444'); + }); + it('should display the Edit flyout', () => { expect(renderResult.getByTestId('addTrustedAppFlyout')); }); @@ -178,6 +186,18 @@ describe('When on the Trusted Apps Page', () => { ); }); + it('should display trusted app data for edit', async () => { + const formNameInput = renderResult.getByTestId( + 'addTrustedAppFlyout-createForm-nameTextField' + ) as HTMLInputElement; + const formDescriptionInput = renderResult.getByTestId( + 'addTrustedAppFlyout-createForm-descriptionField' + ) as HTMLTextAreaElement; + + expect(formNameInput.value).toEqual('one app'); + expect(formDescriptionInput.value).toEqual('a good one'); + }); + describe('and when Save is clicked', () => { it('should call the correct api (PUT)', () => { act(() => { @@ -212,6 +232,101 @@ describe('When on the Trusted Apps Page', () => { }); }); }); + + describe('and attempting to show Edit panel based on URL params', () => { + const TRUSTED_APP_GET_URI = resolvePathVariables(TRUSTED_APPS_GET_API, { + id: '9999-edit-8888', + }); + + const renderAndWaitForGetApi = async () => { + // the store action watcher is setup prior to render because `renderWithListData()` + // also awaits API calls and this action could be missed. + const apiResponseForEditTrustedApp = waitForAction( + 'trustedAppCreationEditItemStateChanged', + { + validate({ payload }) { + return isLoadedResourceState(payload) || isFailedResourceState(payload); + }, + } + ); + + const renderResult = await renderWithListData(); + + await reactTestingLibrary.act(async () => { + await apiResponseForEditTrustedApp; + }); + + return renderResult; + }; + + beforeEach(() => { + // Mock the API GET for the trusted application + const priorMockImplementation = coreStart.http.get.getMockImplementation(); + coreStart.http.get.mockImplementation(async (...args) => { + if ('string' === typeof args[0] && args[0] === TRUSTED_APP_GET_URI) { + return { + data: { + ...getFakeTrustedApp(), + id: '9999-edit-8888', + name: 'one app for edit', + }, + }; + } + if (priorMockImplementation) { + return priorMockImplementation(...args); + } + }); + + reactTestingLibrary.act(() => { + history.push('/trusted_apps?show=edit&id=9999-edit-8888'); + }); + }); + + it('should retrieve trusted app via API using url `id`', async () => { + const renderResult = await renderAndWaitForGetApi(); + + expect(coreStart.http.get).toHaveBeenCalledWith(TRUSTED_APP_GET_URI); + + expect( + (renderResult.getByTestId( + 'addTrustedAppFlyout-createForm-nameTextField' + ) as HTMLInputElement).value + ).toEqual('one app for edit'); + }); + + it('should redirect to list and show toast message if `id` is missing from URL', async () => { + reactTestingLibrary.act(() => { + history.push('/trusted_apps?show=edit&id='); + }); + + await renderAndWaitForGetApi(); + + expect(history.location.search).toEqual(''); + expect(coreStart.notifications.toasts.addWarning.mock.calls[0][0]).toEqual( + 'Unable to edit trusted application (No id provided)' + ); + }); + + it('should redirect to list and show toast message on API error for GET of `id`', async () => { + // Mock the API GET for the trusted application + const priorMockImplementation = coreStart.http.get.getMockImplementation(); + coreStart.http.get.mockImplementation(async (...args) => { + if ('string' === typeof args[0] && args[0] === TRUSTED_APP_GET_URI) { + throw new Error('test: api error response'); + } + if (priorMockImplementation) { + return priorMockImplementation(...args); + } + }); + + await renderAndWaitForGetApi(); + + expect(history.location.search).toEqual(''); + expect(coreStart.notifications.toasts.addWarning.mock.calls[0][0]).toEqual( + 'Unable to edit trusted application (test: api error response)' + ); + }); + }); }); describe('and the Add Trusted App button is clicked', () => { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts index 143914fcc031e..b6e0906da9e24 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts @@ -25,6 +25,7 @@ import { createConditionEntry, createEntryMatch } from './mapping'; import { getTrustedAppsCreateRouteHandler, getTrustedAppsDeleteRouteHandler, + getTrustedAppsGetOneHandler, getTrustedAppsListRouteHandler, getTrustedAppsSummaryRouteHandler, getTrustedAppsUpdateRouteHandler, @@ -337,6 +338,60 @@ describe('handlers', () => { }); }); + describe('getTrustedAppsGetOneHandler', () => { + let getOneHandler: ReturnType; + + beforeEach(() => { + getOneHandler = getTrustedAppsGetOneHandler(appContextMock); + }); + + it('should return single trusted app', async () => { + const mockResponse = httpServerMock.createResponseFactory(); + + exceptionsListClient.getExceptionListItem.mockResolvedValue(EXCEPTION_LIST_ITEM); + + await getOneHandler( + createHandlerContextMock(), + httpServerMock.createKibanaRequest({ params: { page: 1, per_page: 20 } }), + mockResponse + ); + + assertResponse(mockResponse, 'ok', { + data: TRUSTED_APP, + }); + }); + + it('should return 404 if trusted app does not exist', async () => { + const mockResponse = httpServerMock.createResponseFactory(); + + exceptionsListClient.getExceptionListItem.mockResolvedValue(null); + + await getOneHandler( + createHandlerContextMock(), + httpServerMock.createKibanaRequest({ params: { page: 1, per_page: 20 } }), + mockResponse + ); + + assertResponse(mockResponse, 'notFound', expect.any(TrustedAppNotFoundError)); + }); + + it('should log errors if any are encountered', async () => { + const mockResponse = httpServerMock.createResponseFactory(); + const error = new Error('I am an error'); + exceptionsListClient.getExceptionListItem.mockImplementation(async () => { + throw error; + }); + + await getOneHandler( + createHandlerContextMock(), + httpServerMock.createKibanaRequest({ params: { page: 1, per_page: 20 } }), + mockResponse + ); + + expect(appContextMock.logFactory.get('trusted_apps').error).toHaveBeenCalledWith(error); + }); + }); + describe('getTrustedAppsUpdateRouteHandler', () => { let updateHandler: ReturnType; let mockResponse: ReturnType; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.ts index 53d57a33897d8..516b170475c3f 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.ts @@ -12,6 +12,7 @@ import { ExceptionListClient } from '../../../../../lists/server'; import { DeleteTrustedAppsRequestParams, + GetOneTrustedAppRequestParams, GetTrustedAppsListRequest, PostTrustedAppCreateRequest, PutTrustedAppsRequestParams, @@ -21,6 +22,7 @@ import { import { createTrustedApp, deleteTrustedApp, + getTrustedApp, getTrustedAppsList, getTrustedAppsSummary, updateTrustedApp, @@ -76,7 +78,30 @@ export const getTrustedAppsDeleteRouteHandler = ( }; }; -export const getTrustedAppsListRouteHandler = (): RequestHandler< +export const getTrustedAppsGetOneHandler = ( + endpointAppContext: EndpointAppContext +): RequestHandler< + GetOneTrustedAppRequestParams, + unknown, + unknown, + SecuritySolutionRequestHandlerContext +> => { + const logger = endpointAppContext.logFactory.get('trusted_apps'); + + return async (context, req, res) => { + try { + return res.ok({ + body: await getTrustedApp(exceptionListClientFromContext(context), req.params.id), + }); + } catch (error) { + return errorHandler(logger, res, error); + } + }; +}; + +export const getTrustedAppsListRouteHandler = ( + endpointAppContext: EndpointAppContext +): RequestHandler< unknown, GetTrustedAppsListRequest, unknown, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/index.ts index 5dac66046ffc8..7c5272ba8896d 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/index.ts @@ -7,6 +7,7 @@ import { DeleteTrustedAppsRequestSchema, + GetOneTrustedAppRequestSchema, GetTrustedAppsRequestSchema, PostTrustedAppCreateRequestSchema, PutTrustedAppUpdateRequestSchema, @@ -14,6 +15,7 @@ import { import { TRUSTED_APPS_CREATE_API, TRUSTED_APPS_DELETE_API, + TRUSTED_APPS_GET_API, TRUSTED_APPS_LIST_API, TRUSTED_APPS_UPDATE_API, TRUSTED_APPS_SUMMARY_API, @@ -21,6 +23,7 @@ import { import { getTrustedAppsCreateRouteHandler, getTrustedAppsDeleteRouteHandler, + getTrustedAppsGetOneHandler, getTrustedAppsListRouteHandler, getTrustedAppsSummaryRouteHandler, getTrustedAppsUpdateRouteHandler, @@ -38,6 +41,16 @@ export const registerTrustedAppsRoutes = (router: SecuritySolutionPluginRouter) getTrustedAppsDeleteRouteHandler() ); + // GET one + router.get( + { + path: TRUSTED_APPS_GET_API, + validate: GetOneTrustedAppRequestSchema, + options: { authRequired: true }, + }, + getTrustedAppsGetOneHandler(endpointAppContext) + ); + // GET list router.get( { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.test.ts index 04a8c731d2407..db178bf412c52 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.test.ts @@ -17,6 +17,7 @@ import { createConditionEntry, createEntryMatch } from './mapping'; import { createTrustedApp, deleteTrustedApp, + getTrustedApp, getTrustedAppsList, getTrustedAppsSummary, updateTrustedApp, @@ -255,4 +256,18 @@ describe('service', () => { ).rejects.toBeInstanceOf(TrustedAppNotFoundError); }); }); + + describe('getTrustedApp', () => { + it('should return a single trusted app', async () => { + exceptionsListClient.getExceptionListItem.mockResolvedValue(EXCEPTION_LIST_ITEM); + expect(await getTrustedApp(exceptionsListClient, '123')).toEqual({ data: TRUSTED_APP }); + }); + + it('should return Trusted App Not Found Error if it does not exist', async () => { + exceptionsListClient.getExceptionListItem.mockResolvedValue(null); + await expect(getTrustedApp(exceptionsListClient, '123')).rejects.toBeInstanceOf( + TrustedAppNotFoundError + ); + }); + }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.ts index c54f2994ee0f0..486797659cca8 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.ts @@ -10,6 +10,7 @@ import { ENDPOINT_TRUSTED_APPS_LIST_ID } from '../../../../../lists/common'; import { DeleteTrustedAppsRequestParams, + GetOneTrustedAppResponse, GetTrustedAppsListRequest, GetTrustedAppsSummaryResponse, GetTrustedListAppsResponse, @@ -43,6 +44,25 @@ export const deleteTrustedApp = async ( } }; +export const getTrustedApp = async ( + exceptionsListClient: ExceptionListClient, + id: string +): Promise => { + const trustedAppExceptionItem = await exceptionsListClient.getExceptionListItem({ + itemId: '', + id, + namespaceType: 'agnostic', + }); + + if (!trustedAppExceptionItem) { + throw new TrustedAppNotFoundError(id); + } + + return { + data: exceptionListItemToTrustedApp(trustedAppExceptionItem), + }; +}; + export const getTrustedAppsList = async ( exceptionsListClient: ExceptionListClient, { page, per_page: perPage }: GetTrustedAppsListRequest From edd0f1d20b71d62c377ffb27b7d98d4c4c1b5b1c Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Fri, 19 Feb 2021 08:17:14 -0500 Subject: [PATCH 08/31] [Security Solution][Endpoint] Multiple misc. updates/fixes for Edit Trusted Apps (#91656) * correct trusted app schema to ensure `version` is not exposed on TS type for POST * Added updated_by, updated_on properties to TrustedApp * Refactored TA List view to fix bug where card was not updated on a successful edit * Test cases for card interaction from the TA List view * Change title of policy selection to `Assignment` * Selectable Policy CSS adjustments based on UX feedback --- .../common/endpoint/schema/trusted_apps.ts | 2 +- .../common/endpoint/types/trusted_apps.ts | 2 + .../components/item_details_card/index.tsx | 85 ++-- .../pages/trusted_apps/test_utils/index.ts | 2 + .../effected_policy_select.tsx | 30 +- .../components/trusted_app_card/index.tsx | 149 ++++--- .../__snapshots__/index.test.tsx.snap | 81 ++++ .../components/trusted_apps_list/index.tsx | 278 ++++++------ .../pages/trusted_apps/view/translations.ts | 6 + .../view/trusted_apps_page.test.tsx | 415 +++++++++++------- .../routes/trusted_apps/handlers.test.ts | 8 +- .../routes/trusted_apps/mapping.test.ts | 14 + .../endpoint/routes/trusted_apps/mapping.ts | 2 + .../routes/trusted_apps/service.test.ts | 4 + 14 files changed, 657 insertions(+), 421 deletions(-) diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts index a89b584cca8b6..fa30a986f47ce 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts @@ -141,7 +141,7 @@ const createNewTrustedAppForOsScheme = > = memo( ItemDetailsAction.displayName = 'ItemDetailsAction'; -export const ItemDetailsCard: FC = memo(({ children }) => { - const childElements = useMemo( - () => groupChildrenByType(children, [ItemDetailsPropertySummary, ItemDetailsAction]), - [children] - ); - - return ( - - - - - {childElements.get(ItemDetailsPropertySummary)} - - - - - -
{childElements.get(OTHER_NODES)}
-
- {childElements.has(ItemDetailsAction) && ( - - - {childElements.get(ItemDetailsAction)?.map((action, index) => ( - - {action} - - ))} - +export type ItemDetailsCardProps = PropsWithChildren<{ + 'data-test-subj'?: string; +}>; +export const ItemDetailsCard = memo( + ({ children, 'data-test-subj': dataTestSubj }) => { + const childElements = useMemo( + () => groupChildrenByType(children, [ItemDetailsPropertySummary, ItemDetailsAction]), + [children] + ); + + return ( + + + + + {childElements.get(ItemDetailsPropertySummary)} + + + + + +
{childElements.get(OTHER_NODES)}
- )} -
-
-
-
- ); -}); + {childElements.has(ItemDetailsAction) && ( + + + {childElements.get(ItemDetailsAction)?.map((action, index) => ( + + {action} + + ))} + + + )} +
+
+
+
+ ); + } +); ItemDetailsCard.displayName = 'ItemDetailsCard'; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts index 79f3cf2220e8c..faffc6b04a0cd 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts @@ -49,6 +49,8 @@ export const createSampleTrustedApp = (i: number, longTexts?: boolean): TrustedA description: generate(longTexts ? 10 : 1, () => `Trusted App ${i}`).join(' '), created_at: '1 minute ago', created_by: 'someone', + updated_at: '1 minute ago', + updated_by: 'someone', os: OPERATING_SYSTEMS[i % 3], entries: [], effectScope: { type: 'global' }, diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/effected_policy_select/effected_policy_select.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/effected_policy_select/effected_policy_select.tsx index d2b92ac5a9609..30c259b47c7ba 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/effected_policy_select/effected_policy_select.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/effected_policy_select/effected_policy_select.tsx @@ -13,11 +13,13 @@ import { EuiSelectableProps, EuiSwitch, EuiSwitchProps, + EuiText, htmlIdGenerator, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { EuiSelectableOption } from '@elastic/eui/src/components/selectable/selectable_option'; import { FormattedMessage } from '@kbn/i18n/react'; +import styled from 'styled-components'; import { PolicyData } from '../../../../../../../common/endpoint/types'; import { MANAGEMENT_APP_ID } from '../../../../../common/constants'; import { getPolicyDetailPath } from '../../../../../common/routing'; @@ -28,6 +30,13 @@ import { LinkToApp } from '../../../../../../common/components/endpoint/link_to_ const NOOP = () => {}; const DEFAULT_LIST_PROPS: EuiSelectableProps['listProps'] = { bordered: true, showIcons: false }; +const EffectivePolicyFormContainer = styled.div` + .policy-name .euiSelectableListItem__text { + text-decoration: none !important; + color: ${(props) => props.theme.eui.euiTextColor} !important; + } +`; + interface OptionPolicyData { policy: PolicyData; } @@ -75,6 +84,7 @@ export const EffectedPolicySelect = memo( return options .map((policy) => ({ label: policy.name, + className: 'policy-name', prepend: ( ( }, []); return ( - <> + +

+ +

+ + } > ( {listBuilderCallback}
- +
); } ); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/index.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/index.tsx index 477ede53c2009..0520f760d7343 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/index.tsx @@ -21,6 +21,7 @@ import { TextFieldValue } from '../../../../../../common/components/text_field_v import { ItemDetailsAction, ItemDetailsCard, + ItemDetailsCardProps, ItemDetailsPropertySummary, } from '../../../../../../common/components/item_details_card'; @@ -76,86 +77,88 @@ const getEntriesColumnDefinitions = (): Array }, ]; -export interface TrustedAppCardProps { +export type TrustedAppCardProps = Pick & { trustedApp: Immutable; onDelete: (trustedApp: Immutable) => void; onEdit: (trustedApp: Immutable) => void; -} +}; -export const TrustedAppCard = memo(({ trustedApp, onDelete, onEdit }: TrustedAppCardProps) => { - const handleDelete = useCallback(() => onDelete(trustedApp), [onDelete, trustedApp]); - const handleEdit = useCallback(() => onEdit(trustedApp), [onEdit, trustedApp]); +export const TrustedAppCard = memo( + ({ trustedApp, onDelete, onEdit, ...otherProps }) => { + const handleDelete = useCallback(() => onDelete(trustedApp), [onDelete, trustedApp]); + const handleEdit = useCallback(() => onEdit(trustedApp), [onEdit, trustedApp]); - return ( - - - } - /> - } - /> - - } - /> - - } - /> - - } - /> + return ( + + + } + /> + } + /> + + } + /> + + } + /> + + } + /> - getEntriesColumnDefinitions(), [])} - items={useMemo(() => [...trustedApp.entries], [trustedApp.entries])} - badge="and" - responsive - /> + getEntriesColumnDefinitions(), [])} + items={useMemo(() => [...trustedApp.entries], [trustedApp.entries])} + badge="and" + responsive + /> - - {CARD_EDIT_BUTTON_LABEL} - + + {CARD_EDIT_BUTTON_LABEL} + - - {CARD_DELETE_BUTTON_LABEL} - - - ); -}); + + {CARD_DELETE_BUTTON_LABEL} + + + ); + } +); TrustedAppCard.displayName = 'TrustedAppCard'; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_list/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_list/__snapshots__/index.test.tsx.snap index 2d1a135dff5e5..4a78a554ada45 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_list/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_list/__snapshots__/index.test.tsx.snap @@ -635,6 +635,7 @@ exports[`TrustedAppsList renders correctly when item details expanded 1`] = ` >
>; - detailsMapState: [DetailsMap, (value: DetailsMap) => void]; -} - -type ColumnsList = Array>>; -type ActionsList = EuiTableActionsColumnType>['actions']; - const ExpandedRowContent = memo>(({ trustedApp }) => { const dispatch = useDispatch(); const history = useHistory(); @@ -71,141 +55,161 @@ const ExpandedRowContent = memo>(({ trus ); }, [history, location, trustedApp.id]); - return ; + return ( + + ); }); ExpandedRowContent.displayName = 'ExpandedRowContent'; -const toggleItemDetailsInMap = (map: DetailsMap, item: Immutable): DetailsMap => { - const changedMap = { ...map }; - - if (changedMap[item.id]) { - delete changedMap[item.id]; - } else { - changedMap[item.id] = ; - } - - return changedMap; -}; - -const getActionDefinitions = ({ dispatch }: TrustedAppsListContext): ActionsList => [ - { - name: LIST_ACTIONS.delete.name, - description: LIST_ACTIONS.delete.description, - 'data-test-subj': 'trustedAppDeleteAction', - isPrimary: true, - icon: 'trash', - color: 'danger', - type: 'icon', - onClick: (item: Immutable) => { - dispatch({ - type: 'trustedAppDeletionDialogStarted', - payload: { entry: item }, - }); - }, - }, -]; - -const getColumnDefinitions = (context: TrustedAppsListContext): ColumnsList => { - const [itemDetailsMap, setItemDetailsMap] = context.detailsMapState; - - return [ - { - field: 'name', - name: PROPERTY_TITLES.name, - render(value: TrustedApp['name'], record: Immutable) { - return ( - - ); +export const TrustedAppsList = memo(() => { + const dispatch = useDispatch(); + + const [showDetailsFor, setShowDetailsFor] = useState<{ [key: string]: boolean }>({}); + + // Cast below is needed because EuiBasicTable expects listItems to be mutable + const listItems = useTrustedAppsSelector(getListItems) as TrustedApp[]; + const pagination = useTrustedAppsSelector(getListPagination); + const listError = useTrustedAppsSelector(getListErrorMessage); + const isLoading = useTrustedAppsSelector(isListLoading); + + const toggleShowDetailsFor = useCallback((trustedAppId) => { + setShowDetailsFor((prevState) => { + const newState = { ...prevState }; + if (prevState[trustedAppId]) { + delete newState[trustedAppId]; + } else { + newState[trustedAppId] = true; + } + return newState; + }); + }, []); + + const detailsMap = useMemo(() => { + return Object.keys(showDetailsFor).reduce((expandMap, trustedAppId) => { + const trustedApp = listItems.find((ta) => ta.id === trustedAppId); + + if (trustedApp) { + expandMap[trustedAppId] = ; + } + + return expandMap; + }, {}); + }, [listItems, showDetailsFor]); + + const handleTableOnChange = useTrustedAppsNavigateCallback(({ page }) => ({ + page_index: page.index, + page_size: page.size, + })); + + const tableColumns: Array>> = useMemo(() => { + return [ + { + field: 'name', + name: PROPERTY_TITLES.name, + 'data-test-subj': 'trustedAppNameTableCell', + render(value: TrustedApp['name']) { + return ( + + ); + }, }, - }, - { - field: 'os', - name: PROPERTY_TITLES.os, - render(value: TrustedApp['os'], record: Immutable) { - return ( - - ); + { + field: 'os', + name: PROPERTY_TITLES.os, + render(value: TrustedApp['os']) { + return ( + + ); + }, }, - }, - { - field: 'created_at', - name: PROPERTY_TITLES.created_at, - render(value: TrustedApp['created_at'], record: Immutable) { - return ( - - ); + { + field: 'created_at', + name: PROPERTY_TITLES.created_at, + render(value: TrustedApp['created_at']) { + return ( + + ); + }, }, - }, - { - field: 'created_by', - name: PROPERTY_TITLES.created_by, - render(value: TrustedApp['created_by'], record: Immutable) { - return ( - - ); + { + field: 'created_by', + name: PROPERTY_TITLES.created_by, + render(value: TrustedApp['created_by']) { + return ( + + ); + }, }, - }, - { - name: ACTIONS_COLUMN_TITLE, - actions: getActionDefinitions(context), - }, - { - align: RIGHT_ALIGNMENT, - width: '40px', - isExpander: true, - render(item: Immutable) { - return ( - setItemDetailsMap(toggleItemDetailsInMap(itemDetailsMap, item))} - aria-label={itemDetailsMap[item.id] ? 'Collapse' : 'Expand'} - iconType={itemDetailsMap[item.id] ? 'arrowUp' : 'arrowDown'} - data-test-subj="trustedAppsListItemExpandButton" - /> - ); + { + name: ACTIONS_COLUMN_TITLE, + actions: [ + { + name: LIST_ACTIONS.delete.name, + description: LIST_ACTIONS.delete.description, + 'data-test-subj': 'trustedAppDeleteAction', + isPrimary: true, + icon: 'trash', + color: 'danger', + type: 'icon', + onClick: (item: Immutable) => { + dispatch({ + type: 'trustedAppDeletionDialogStarted', + payload: { entry: item }, + }); + }, + }, + ], }, - }, - ]; -}; - -export const TrustedAppsList = memo(() => { - const [detailsMap, setDetailsMap] = useState({}); - const pagination = useTrustedAppsSelector(getListPagination); - const listItems = useTrustedAppsSelector(getListItems); - const dispatch = useDispatch(); + { + align: RIGHT_ALIGNMENT, + width: '40px', + isExpander: true, + render({ id }: Immutable) { + return ( + toggleShowDetailsFor(id)} + aria-label={detailsMap[id] ? 'Collapse' : 'Expand'} + iconType={detailsMap[id] ? 'arrowUp' : 'arrowDown'} + data-test-subj="trustedAppsListItemExpandButton" + /> + ); + }, + }, + ]; + }, [detailsMap, dispatch, toggleShowDetailsFor]); return ( getColumnDefinitions({ dispatch, detailsMapState: [detailsMap, setDetailsMap] }), - [dispatch, detailsMap] - )} - items={useMemo(() => [...listItems], [listItems])} - error={useTrustedAppsSelector(getListErrorMessage)} - loading={useTrustedAppsSelector(isListLoading)} + columns={tableColumns} + items={listItems} + error={listError} + loading={isLoading} itemId="id" itemIdToExpandedRowMap={detailsMap} isExpandable={true} pagination={pagination} - onChange={useTrustedAppsNavigateCallback(({ page }) => ({ - page_index: page.index, - page_size: page.size, - }))} + onChange={handleTableOnChange} data-test-subj="trustedAppsList" /> ); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts index 12fc21031aca5..57ca80930ad7d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts @@ -73,6 +73,12 @@ export const PROPERTY_TITLES: Readonly< created_by: i18n.translate('xpack.securitySolution.trustedapps.trustedapp.createdBy', { defaultMessage: 'Created By', }), + updated_at: i18n.translate('xpack.securitySolution.trustedapps.trustedapp.updatedAt', { + defaultMessage: 'Date Updated', + }), + updated_by: i18n.translate('xpack.securitySolution.trustedapps.trustedapp.updatedBy', { + defaultMessage: 'Updated By', + }), description: i18n.translate('xpack.securitySolution.trustedapps.trustedapp.description', { defaultMessage: 'Description', }), diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx index 497ef50855c6a..c7a4d2864f4b8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx @@ -32,6 +32,7 @@ import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_da import { isFailedResourceState, isLoadedResourceState } from '../state'; import { forceHTMLElementOffsetWidth } from './components/effected_policy_select/test_utils'; import { resolvePathVariables } from '../service/utils'; +import { toUpdateTrustedApp } from '../../../../../common/endpoint/service/trusted_apps/to_update_trusted_app'; jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ htmlIdGenerator: () => () => 'mockId', @@ -58,6 +59,8 @@ describe('When on the Trusted Apps Page', () => { os: OperatingSystem.WINDOWS, created_at: '2021-01-04T13:55:00.561Z', created_by: 'me', + updated_at: '2021-01-04T13:55:00.561Z', + updated_by: 'me', description: 'a good one', effectScope: { type: 'global' }, entries: [ @@ -70,6 +73,19 @@ describe('When on the Trusted Apps Page', () => { ], }); + const createListApiResponse = ( + page: number = 1, + // eslint-disable-next-line @typescript-eslint/naming-convention + per_page: number = 20 + ): GetTrustedListAppsResponse => { + return { + data: [getFakeTrustedApp()], + total: 50, // << Should be a value large enough to fulfill two pages + page, + per_page, + }; + }; + const mockListApis = (http: AppContextTestRender['coreStart']['http']) => { const currentGetHandler = http.get.getMockImplementation(); @@ -79,12 +95,10 @@ describe('When on the Trusted Apps Page', () => { const httpOptions = args[1] as HttpFetchOptions; if (path === TRUSTED_APPS_LIST_API) { - return { - data: [getFakeTrustedApp()], - total: 50, // << Should be a value large enough to fulfill two pages - page: httpOptions?.query?.page ?? 1, - per_page: httpOptions?.query?.per_page ?? 20, - }; + return createListApiResponse( + Number(httpOptions?.query?.page ?? 1), + Number(httpOptions?.query?.per_page ?? 20) + ); } if (path === PACKAGE_POLICY_API_ROUTES.LIST_PATTERN) { @@ -152,179 +166,248 @@ describe('When on the Trusted Apps Page', () => { expect(addButton.textContent).toBe('Add Trusted Application'); }); - describe('and the edit trusted app button is clicked', () => { - let renderResult: ReturnType; + describe('and the Grid view is being displayed', () => { + describe('and the edit trusted app button is clicked', () => { + let renderResult: ReturnType; - beforeEach(async () => { - renderResult = await renderWithListData(); - act(() => { - fireEvent.click(renderResult.getByTestId('trustedAppEditButton')); + beforeEach(async () => { + renderResult = await renderWithListData(); + act(() => { + fireEvent.click(renderResult.getByTestId('trustedAppEditButton')); + }); }); - }); - it('should persist edit params to url', () => { - expect(history.location.search).toEqual('?show=edit&id=1111-2222-3333-4444'); - }); + it('should persist edit params to url', () => { + expect(history.location.search).toEqual('?show=edit&id=1111-2222-3333-4444'); + }); - it('should display the Edit flyout', () => { - expect(renderResult.getByTestId('addTrustedAppFlyout')); - }); + it('should display the Edit flyout', () => { + expect(renderResult.getByTestId('addTrustedAppFlyout')); + }); - it('should NOT display the about info for trusted apps', () => { - expect(renderResult.queryByTestId('addTrustedAppFlyout-about')).toBeNull(); - }); + it('should NOT display the about info for trusted apps', () => { + expect(renderResult.queryByTestId('addTrustedAppFlyout-about')).toBeNull(); + }); - it('should show correct flyout title', () => { - expect(renderResult.getByTestId('addTrustedAppFlyout-headerTitle').textContent).toBe( - 'Edit trusted application' - ); - }); + it('should show correct flyout title', () => { + expect(renderResult.getByTestId('addTrustedAppFlyout-headerTitle').textContent).toBe( + 'Edit trusted application' + ); + }); - it('should display the expected text for the Save button', () => { - expect(renderResult.getByTestId('addTrustedAppFlyout-createButton').textContent).toEqual( - 'Save' - ); - }); + it('should display the expected text for the Save button', () => { + expect(renderResult.getByTestId('addTrustedAppFlyout-createButton').textContent).toEqual( + 'Save' + ); + }); - it('should display trusted app data for edit', async () => { - const formNameInput = renderResult.getByTestId( - 'addTrustedAppFlyout-createForm-nameTextField' - ) as HTMLInputElement; - const formDescriptionInput = renderResult.getByTestId( - 'addTrustedAppFlyout-createForm-descriptionField' - ) as HTMLTextAreaElement; + it('should display trusted app data for edit', async () => { + const formNameInput = renderResult.getByTestId( + 'addTrustedAppFlyout-createForm-nameTextField' + ) as HTMLInputElement; + const formDescriptionInput = renderResult.getByTestId( + 'addTrustedAppFlyout-createForm-descriptionField' + ) as HTMLTextAreaElement; - expect(formNameInput.value).toEqual('one app'); - expect(formDescriptionInput.value).toEqual('a good one'); - }); + expect(formNameInput.value).toEqual('one app'); + expect(formDescriptionInput.value).toEqual('a good one'); + }); - describe('and when Save is clicked', () => { - it('should call the correct api (PUT)', () => { - act(() => { - fireEvent.click(renderResult.getByTestId('addTrustedAppFlyout-createButton')); - }); + describe('and when Save is clicked', () => { + it('should call the correct api (PUT)', () => { + act(() => { + fireEvent.click(renderResult.getByTestId('addTrustedAppFlyout-createButton')); + }); - expect(coreStart.http.put).toHaveBeenCalledTimes(1); - - const lastCallToPut = (coreStart.http.put.mock.calls[0] as unknown) as [ - string, - HttpFetchOptions - ]; - - expect(lastCallToPut[0]).toEqual('/api/endpoint/trusted_apps/1111-2222-3333-4444'); - expect(JSON.parse(lastCallToPut[1].body as string)).toEqual({ - name: 'one app', - os: 'windows', - entries: [ - { - field: 'process.executable.caseless', - value: 'one/two', - operator: 'included', - type: 'match', + expect(coreStart.http.put).toHaveBeenCalledTimes(1); + + const lastCallToPut = (coreStart.http.put.mock.calls[0] as unknown) as [ + string, + HttpFetchOptions + ]; + + expect(lastCallToPut[0]).toEqual('/api/endpoint/trusted_apps/1111-2222-3333-4444'); + expect(JSON.parse(lastCallToPut[1].body as string)).toEqual({ + name: 'one app', + os: 'windows', + entries: [ + { + field: 'process.executable.caseless', + value: 'one/two', + operator: 'included', + type: 'match', + }, + ], + description: 'a good one', + effectScope: { + type: 'global', }, - ], - description: 'a good one', - effectScope: { - type: 'global', - }, - version: 'abc123', + version: 'abc123', + }); }); }); }); - }); - describe('and attempting to show Edit panel based on URL params', () => { - const TRUSTED_APP_GET_URI = resolvePathVariables(TRUSTED_APPS_GET_API, { - id: '9999-edit-8888', - }); + describe('and attempting to show Edit panel based on URL params', () => { + const TRUSTED_APP_GET_URI = resolvePathVariables(TRUSTED_APPS_GET_API, { + id: '9999-edit-8888', + }); - const renderAndWaitForGetApi = async () => { - // the store action watcher is setup prior to render because `renderWithListData()` - // also awaits API calls and this action could be missed. - const apiResponseForEditTrustedApp = waitForAction( - 'trustedAppCreationEditItemStateChanged', - { - validate({ payload }) { - return isLoadedResourceState(payload) || isFailedResourceState(payload); - }, - } - ); + const renderAndWaitForGetApi = async () => { + // the store action watcher is setup prior to render because `renderWithListData()` + // also awaits API calls and this action could be missed. + const apiResponseForEditTrustedApp = waitForAction( + 'trustedAppCreationEditItemStateChanged', + { + validate({ payload }) { + return isLoadedResourceState(payload) || isFailedResourceState(payload); + }, + } + ); + + const renderResult = await renderWithListData(); - const renderResult = await renderWithListData(); + await reactTestingLibrary.act(async () => { + await apiResponseForEditTrustedApp; + }); - await reactTestingLibrary.act(async () => { - await apiResponseForEditTrustedApp; + return renderResult; + }; + + beforeEach(() => { + // Mock the API GET for the trusted application + const priorMockImplementation = coreStart.http.get.getMockImplementation(); + coreStart.http.get.mockImplementation(async (...args) => { + if ('string' === typeof args[0] && args[0] === TRUSTED_APP_GET_URI) { + return { + data: { + ...getFakeTrustedApp(), + id: '9999-edit-8888', + name: 'one app for edit', + }, + }; + } + if (priorMockImplementation) { + return priorMockImplementation(...args); + } + }); + + reactTestingLibrary.act(() => { + history.push('/trusted_apps?show=edit&id=9999-edit-8888'); + }); }); - return renderResult; - }; + it('should retrieve trusted app via API using url `id`', async () => { + const renderResult = await renderAndWaitForGetApi(); - beforeEach(() => { - // Mock the API GET for the trusted application - const priorMockImplementation = coreStart.http.get.getMockImplementation(); - coreStart.http.get.mockImplementation(async (...args) => { - if ('string' === typeof args[0] && args[0] === TRUSTED_APP_GET_URI) { - return { - data: { - ...getFakeTrustedApp(), - id: '9999-edit-8888', - name: 'one app for edit', - }, - }; - } - if (priorMockImplementation) { - return priorMockImplementation(...args); - } + expect(coreStart.http.get).toHaveBeenCalledWith(TRUSTED_APP_GET_URI); + + expect( + (renderResult.getByTestId( + 'addTrustedAppFlyout-createForm-nameTextField' + ) as HTMLInputElement).value + ).toEqual('one app for edit'); }); - reactTestingLibrary.act(() => { - history.push('/trusted_apps?show=edit&id=9999-edit-8888'); + it('should redirect to list and show toast message if `id` is missing from URL', async () => { + reactTestingLibrary.act(() => { + history.push('/trusted_apps?show=edit&id='); + }); + + await renderAndWaitForGetApi(); + + expect(history.location.search).toEqual(''); + expect(coreStart.notifications.toasts.addWarning.mock.calls[0][0]).toEqual( + 'Unable to edit trusted application (No id provided)' + ); }); - }); - it('should retrieve trusted app via API using url `id`', async () => { - const renderResult = await renderAndWaitForGetApi(); + it('should redirect to list and show toast message on API error for GET of `id`', async () => { + // Mock the API GET for the trusted application + const priorMockImplementation = coreStart.http.get.getMockImplementation(); + coreStart.http.get.mockImplementation(async (...args) => { + if ('string' === typeof args[0] && args[0] === TRUSTED_APP_GET_URI) { + throw new Error('test: api error response'); + } + if (priorMockImplementation) { + return priorMockImplementation(...args); + } + }); - expect(coreStart.http.get).toHaveBeenCalledWith(TRUSTED_APP_GET_URI); + await renderAndWaitForGetApi(); - expect( - (renderResult.getByTestId( - 'addTrustedAppFlyout-createForm-nameTextField' - ) as HTMLInputElement).value - ).toEqual('one app for edit'); + expect(history.location.search).toEqual(''); + expect(coreStart.notifications.toasts.addWarning.mock.calls[0][0]).toEqual( + 'Unable to edit trusted application (test: api error response)' + ); + }); }); + }); + + describe('and the List view is being displayed', () => { + let renderResult: ReturnType; - it('should redirect to list and show toast message if `id` is missing from URL', async () => { + const expandFirstRow = () => { reactTestingLibrary.act(() => { - history.push('/trusted_apps?show=edit&id='); + fireEvent.click(renderResult.getByTestId('trustedAppsListItemExpandButton')); }); + }; - await renderAndWaitForGetApi(); + beforeEach(async () => { + reactTestingLibrary.act(() => { + history.push('/trusted_apps?view_type=list'); + }); - expect(history.location.search).toEqual(''); - expect(coreStart.notifications.toasts.addWarning.mock.calls[0][0]).toEqual( - 'Unable to edit trusted application (No id provided)' - ); + renderResult = await renderWithListData(); }); - it('should redirect to list and show toast message on API error for GET of `id`', async () => { - // Mock the API GET for the trusted application - const priorMockImplementation = coreStart.http.get.getMockImplementation(); - coreStart.http.get.mockImplementation(async (...args) => { - if ('string' === typeof args[0] && args[0] === TRUSTED_APP_GET_URI) { - throw new Error('test: api error response'); - } - if (priorMockImplementation) { - return priorMockImplementation(...args); - } + it('should display the list', () => { + expect(renderResult.getByTestId('trustedAppsList')); + }); + + it('should show a card when row is expanded', () => { + expandFirstRow(); + expect(renderResult.getByTestId('trustedAppCard')); + }); + + it('should show Edit flyout when edit button on card is clicked', () => { + expandFirstRow(); + reactTestingLibrary.act(() => { + fireEvent.click(renderResult.getByTestId('trustedAppEditButton')); }); + expect(renderResult.findByTestId('addTrustedAppFlyout')); + }); - await renderAndWaitForGetApi(); + it('should reflect updated information on row and card when updated data is received', async () => { + expandFirstRow(); + reactTestingLibrary.act(() => { + const updatedListContent = createListApiResponse(); + updatedListContent.data[0]!.name = 'updated trusted app'; + updatedListContent.data[0]!.description = 'updated trusted app description'; + + mockedContext.store.dispatch({ + type: 'trustedAppsListResourceStateChanged', + payload: { + newState: { + type: 'LoadedResourceState', + data: { + items: updatedListContent.data, + pageIndex: updatedListContent.page, + pageSize: updatedListContent.per_page, + totalItemsCount: updatedListContent.total, + timestamp: Date.now(), + }, + }, + }, + }); + }); - expect(history.location.search).toEqual(''); - expect(coreStart.notifications.toasts.addWarning.mock.calls[0][0]).toEqual( - 'Unable to edit trusted application (test: api error response)' + // The additional prefix of `Name` is due to the hidden element in DOM that is only shown + // for mobile devices (inserted by the EuiBasicTable) + expect(renderResult.getByTestId('trustedAppNameTableCell').textContent).toEqual( + 'Nameupdated trusted app' ); + expect(renderResult.getByText('updated trusted app description')); }); }); }); @@ -335,7 +418,14 @@ describe('When on the Trusted Apps Page', () => { > => { const renderResult = render(); await act(async () => { - await waitForAction('trustedAppsListResourceStateChanged'); + await Promise.all([ + waitForAction('trustedAppsListResourceStateChanged'), + waitForAction('trustedAppsExistStateChanged', { + validate({ payload }) { + return isLoadedResourceState(payload); + }, + }), + ]); }); act(() => { @@ -403,50 +493,45 @@ describe('When on the Trusted Apps Page', () => { it('should close flyout if cancel button is clicked', async () => { const { getByTestId, queryByTestId } = await renderAndClickAddButton(); const cancelButton = getByTestId('addTrustedAppFlyout-cancelButton'); - reactTestingLibrary.act(() => { + await reactTestingLibrary.act(async () => { fireEvent.click(cancelButton, { button: 1 }); + await waitForAction('trustedAppCreationDialogClosed'); }); - expect(queryByTestId('addTrustedAppFlyout')).toBeNull(); expect(history.location.search).toBe(''); + expect(queryByTestId('addTrustedAppFlyout')).toBeNull(); }); it('should close flyout if flyout close button is clicked', async () => { const { getByTestId, queryByTestId } = await renderAndClickAddButton(); const flyoutCloseButton = getByTestId('euiFlyoutCloseButton'); - reactTestingLibrary.act(() => { + await reactTestingLibrary.act(async () => { fireEvent.click(flyoutCloseButton, { button: 1 }); + await waitForAction('trustedAppCreationDialogClosed'); }); expect(queryByTestId('addTrustedAppFlyout')).toBeNull(); expect(history.location.search).toBe(''); }); describe('and when the form data is valid', () => { - const fillInCreateForm = ({ getByTestId }: ReturnType) => { - reactTestingLibrary.act(() => { - fireEvent.change(getByTestId('addTrustedAppFlyout-createForm-nameTextField'), { - target: { value: 'trusted app A' }, - }); - }); - reactTestingLibrary.act(() => { - fireEvent.change( - getByTestId('addTrustedAppFlyout-createForm-conditionsBuilder-group1-entry0-value'), - { target: { value: '44ed10b389dbcd1cf16cec79d16d7378' } } - ); - }); - reactTestingLibrary.act(() => { - fireEvent.change(getByTestId('addTrustedAppFlyout-createForm-descriptionField'), { - target: { value: 'let this be' }, - }); + const fillInCreateForm = async () => { + mockedContext.store.dispatch({ + type: 'trustedAppCreationDialogFormStateUpdated', + payload: { + isValid: true, + entry: toUpdateTrustedApp(getFakeTrustedApp()), + }, }); }; it('should enable the Flyout Add button', async () => { const renderResult = await renderAndClickAddButton(); - const { getByTestId } = renderResult; - fillInCreateForm(renderResult); - const flyoutAddButton = getByTestId( + + await fillInCreateForm(); + + const flyoutAddButton = renderResult.getByTestId( 'addTrustedAppFlyout-createButton' ) as HTMLButtonElement; + expect(flyoutAddButton.disabled).toBe(false); }); @@ -472,7 +557,7 @@ describe('When on the Trusted Apps Page', () => { ); renderResult = await renderAndClickAddButton(); - fillInCreateForm(renderResult); + await fillInCreateForm(); const userClickedSaveActionWatcher = waitForAction('trustedAppCreationDialogConfirmed'); reactTestingLibrary.act(() => { fireEvent.click(renderResult.getByTestId('addTrustedAppFlyout-createButton'), { @@ -521,6 +606,8 @@ describe('When on the Trusted Apps Page', () => { version: 'abc123', created_at: '2020-09-16T14:09:45.484Z', created_by: 'kibana', + updated_at: '2021-01-04T13:55:00.561Z', + updated_by: 'me', }, }; await reactTestingLibrary.act(async () => { @@ -539,7 +626,7 @@ describe('When on the Trusted Apps Page', () => { it('should show success toast notification', async () => { expect(coreStart.notifications.toasts.addSuccess.mock.calls[0][0]).toEqual( - '"trusted app A" has been added to the Trusted Applications list.' + '"one app" has been added to the Trusted Applications list.' ); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts index b6e0906da9e24..3b70bef4373df 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts @@ -55,8 +55,8 @@ const EXCEPTION_LIST_ITEM: ExceptionListItemSchema = { tags: ['policy:all'], type: 'simple', tie_breaker_id: '123', - updated_at: '11/11/2011T11:11:11.111', - updated_by: 'admin', + updated_at: '2021-01-04T13:55:00.561Z', + updated_by: 'me', }; const NEW_TRUSTED_APP: NewTrustedApp = { @@ -75,6 +75,8 @@ const TRUSTED_APP: TrustedApp = { version: 'abc123', created_at: '11/11/2011T11:11:11.111', created_by: 'admin', + updated_at: '2021-01-04T13:55:00.561Z', + updated_by: 'me', name: 'linux trusted app 1', description: 'Linux trusted app 1', os: OperatingSystem.LINUX, @@ -418,6 +420,8 @@ describe('handlers', () => { data: { created_at: '11/11/2011T11:11:11.111', created_by: 'admin', + updated_at: '11/11/2011T11:11:11.111', + updated_by: 'admin', description: 'Linux trusted app 1', effectScope: { type: 'global', diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.test.ts index 91e3ed870d8c1..68ff7d03e413a 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.test.ts @@ -267,6 +267,8 @@ describe('mapping', () => { effectScope: { type: 'global' }, created_at: '11/11/2011T11:11:11.111', created_by: 'admin', + updated_at: '11/11/2011T11:11:11.111', + updated_by: 'admin', os: OperatingSystem.LINUX, entries: [createConditionEntry(ConditionEntryField.PATH, '/bin/malware')], } @@ -292,6 +294,8 @@ describe('mapping', () => { effectScope: { type: 'global' }, created_at: '11/11/2011T11:11:11.111', created_by: 'admin', + updated_at: '11/11/2011T11:11:11.111', + updated_by: 'admin', os: OperatingSystem.MAC, entries: [createConditionEntry(ConditionEntryField.PATH, '/bin/malware')], } @@ -317,6 +321,8 @@ describe('mapping', () => { effectScope: { type: 'global' }, created_at: '11/11/2011T11:11:11.111', created_by: 'admin', + updated_at: '11/11/2011T11:11:11.111', + updated_by: 'admin', os: OperatingSystem.WINDOWS, entries: [createConditionEntry(ConditionEntryField.PATH, 'C:\\Program Files\\Malware')], } @@ -347,6 +353,8 @@ describe('mapping', () => { effectScope: { type: 'global' }, created_at: '11/11/2011T11:11:11.111', created_by: 'admin', + updated_at: '11/11/2011T11:11:11.111', + updated_by: 'admin', os: OperatingSystem.WINDOWS, entries: [createConditionEntry(ConditionEntryField.SIGNER, 'Microsoft Windows')], } @@ -372,6 +380,8 @@ describe('mapping', () => { effectScope: { type: 'global' }, created_at: '11/11/2011T11:11:11.111', created_by: 'admin', + updated_at: '11/11/2011T11:11:11.111', + updated_by: 'admin', os: OperatingSystem.LINUX, entries: [ createConditionEntry(ConditionEntryField.HASH, '1234234659af249ddf3e40864e9fb241'), @@ -401,6 +411,8 @@ describe('mapping', () => { effectScope: { type: 'global' }, created_at: '11/11/2011T11:11:11.111', created_by: 'admin', + updated_at: '11/11/2011T11:11:11.111', + updated_by: 'admin', os: OperatingSystem.LINUX, entries: [ createConditionEntry( @@ -436,6 +448,8 @@ describe('mapping', () => { effectScope: { type: 'global' }, created_at: '11/11/2011T11:11:11.111', created_by: 'admin', + updated_at: '11/11/2011T11:11:11.111', + updated_by: 'admin', os: OperatingSystem.LINUX, entries: [ createConditionEntry( diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.ts index eeda74fb9c5bb..c6048e5725c88 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.ts @@ -124,6 +124,8 @@ export const exceptionListItemToTrustedApp = ( effectScope: tagsToEffectScope(exceptionListItem.tags), created_at: exceptionListItem.created_at, created_by: exceptionListItem.created_by, + updated_at: exceptionListItem.updated_at, + updated_by: exceptionListItem.updated_by, ...(os === OperatingSystem.LINUX || os === OperatingSystem.MAC ? { os, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.test.ts index db178bf412c52..b0400e64778f0 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.test.ts @@ -57,6 +57,8 @@ const TRUSTED_APP: TrustedApp = { version: 'abc123', created_at: '11/11/2011T11:11:11.111', created_by: 'admin', + updated_at: '11/11/2011T11:11:11.111', + updated_by: 'admin', name: 'linux trusted app 1', description: 'Linux trusted app 1', os: OperatingSystem.LINUX, @@ -205,6 +207,8 @@ describe('service', () => { data: { created_at: '11/11/2011T11:11:11.111', created_by: 'admin', + updated_at: '11/11/2011T11:11:11.111', + updated_by: 'admin', description: 'updated description', effectScope: { type: 'global', From 8b3bbf13ca0998e87d0aea4837dbb00d68b87237 Mon Sep 17 00:00:00 2001 From: Paul Tavares Date: Mon, 22 Feb 2021 11:52:42 -0500 Subject: [PATCH 09/31] Fix failing server tests --- .../server/endpoint/routes/trusted_apps/handlers.test.ts | 6 ++++-- .../server/endpoint/routes/trusted_apps/handlers.ts | 7 ++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts index 3b70bef4373df..11305f06edaf9 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts @@ -377,9 +377,11 @@ describe('handlers', () => { assertResponse(mockResponse, 'notFound', expect.any(TrustedAppNotFoundError)); }); - it('should log errors if any are encountered', async () => { + it.each([ + [new TrustedAppNotFoundError('123')], + [new TrustedAppVersionConflictError('123', new Error('some conflict error'))], + ])('should log error: %s', async (error) => { const mockResponse = httpServerMock.createResponseFactory(); - const error = new Error('I am an error'); exceptionsListClient.getExceptionListItem.mockImplementation(async () => { throw error; }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.ts index 516b170475c3f..3122b2b570716 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.ts @@ -46,17 +46,18 @@ const errorHandler = ( res: KibanaResponseFactory, error: E ): IKibanaResponse => { - logger.error(error); - if (error instanceof TrustedAppNotFoundError) { + logger.error(error); return res.notFound({ body: error }); } if (error instanceof TrustedAppVersionConflictError) { + logger.error(error); return res.conflict({ body: error }); } - return res.internalError({ body: error }); + // Kibana will take care of `500` errors when the handler `throw`'s, including logging the error + throw error; }; export const getTrustedAppsDeleteRouteHandler = ( From 1d2318696590dd8c9f5da7909d7e54ca73210324 Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Wed, 24 Feb 2021 13:17:46 -0500 Subject: [PATCH 10/31] [Security Solution][Endpoint] Trusted Apps list API KQL filtering support (#92611) * Fix bad merge from master * Fix trusted apps generator * Add `kuery` to the GET (list) Trusted Apps api --- .../common/endpoint/schema/trusted_apps.ts | 1 + .../scripts/endpoint/trusted_apps/index.ts | 7 +++- .../routes/trusted_apps/handlers.test.ts | 33 ++++++++++++++++--- .../endpoint/routes/trusted_apps/index.ts | 14 +++++--- .../routes/trusted_apps/service.test.ts | 24 +++++++++++++- .../endpoint/routes/trusted_apps/service.ts | 4 +-- .../security_solution/server/plugin.ts | 2 +- 7 files changed, 71 insertions(+), 14 deletions(-) diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts index fa30a986f47ce..f5ed41d3cdf93 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts @@ -25,6 +25,7 @@ export const GetTrustedAppsRequestSchema = { query: schema.object({ page: schema.maybe(schema.number({ defaultValue: 1, min: 1 })), per_page: schema.maybe(schema.number({ defaultValue: 20, min: 1 })), + kuery: schema.maybe(schema.string()), }), }; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/trusted_apps/index.ts b/x-pack/plugins/security_solution/scripts/endpoint/trusted_apps/index.ts index 582969f1dcd45..edefaa9c2a50c 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/trusted_apps/index.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/trusted_apps/index.ts @@ -98,10 +98,13 @@ const generateTrustedAppEntry: (options?: GenerateTrustedAppEntryOptions) => obj os = randomOperatingSystem(), name = randomName(), } = {}): NewTrustedApp => { - return { + const newTrustedApp: NewTrustedApp = { description: `Generator says we trust ${name}`, name, os, + effectScope: { + type: 'global', + }, entries: [ { // @ts-ignore @@ -119,6 +122,8 @@ const generateTrustedAppEntry: (options?: GenerateTrustedAppEntryOptions) => obj }, ], }; + + return newTrustedApp; }; const randomN = (max: number): number => Math.floor(Math.random() * max); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts index 11305f06edaf9..3807ace0068e3 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts @@ -243,7 +243,7 @@ describe('handlers', () => { await getTrustedAppsListHandler( createHandlerContextMock(), - httpServerMock.createKibanaRequest({ params: { page: 1, per_page: 20 } }), + httpServerMock.createKibanaRequest({ query: { page: 1, per_page: 20 } }), mockResponse ); @@ -269,6 +269,31 @@ describe('handlers', () => { ) ).rejects.toThrowError(error); }); + + it('should pass all params to the service', async () => { + const mockResponse = httpServerMock.createResponseFactory(); + + exceptionsListClient.findExceptionListItem.mockResolvedValue({ + data: [EXCEPTION_LIST_ITEM], + page: 5, + per_page: 13, + total: 100, + }); + + const requestContext = createHandlerContextMock(); + + await getTrustedAppsListHandler( + requestContext, + httpServerMock.createKibanaRequest({ + query: { page: 5, per_page: 13, kuery: 'some-param.key: value' }, + }), + mockResponse + ); + + expect(exceptionsListClient.findExceptionListItem).toHaveBeenCalledWith( + expect.objectContaining({ filter: 'some-param.key: value', page: 5, perPage: 13 }) + ); + }); }); describe('getTrustedAppsSummaryHandler', () => { @@ -354,7 +379,7 @@ describe('handlers', () => { await getOneHandler( createHandlerContextMock(), - httpServerMock.createKibanaRequest({ params: { page: 1, per_page: 20 } }), + httpServerMock.createKibanaRequest({ query: { page: 1, per_page: 20 } }), mockResponse ); @@ -370,7 +395,7 @@ describe('handlers', () => { await getOneHandler( createHandlerContextMock(), - httpServerMock.createKibanaRequest({ params: { page: 1, per_page: 20 } }), + httpServerMock.createKibanaRequest({ query: { page: 1, per_page: 20 } }), mockResponse ); @@ -388,7 +413,7 @@ describe('handlers', () => { await getOneHandler( createHandlerContextMock(), - httpServerMock.createKibanaRequest({ params: { page: 1, per_page: 20 } }), + httpServerMock.createKibanaRequest({ query: { page: 1, per_page: 20 } }), mockResponse ); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/index.ts index 7c5272ba8896d..458fa8e36d3da 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/index.ts @@ -29,8 +29,12 @@ import { getTrustedAppsUpdateRouteHandler, } from './handlers'; import { SecuritySolutionPluginRouter } from '../../../types'; +import { EndpointAppContext } from '../../types'; -export const registerTrustedAppsRoutes = (router: SecuritySolutionPluginRouter) => { +export const registerTrustedAppsRoutes = ( + router: SecuritySolutionPluginRouter, + endpointAppContext: EndpointAppContext +) => { // DELETE one router.delete( { @@ -38,7 +42,7 @@ export const registerTrustedAppsRoutes = (router: SecuritySolutionPluginRouter) validate: DeleteTrustedAppsRequestSchema, options: { authRequired: true }, }, - getTrustedAppsDeleteRouteHandler() + getTrustedAppsDeleteRouteHandler(endpointAppContext) ); // GET one @@ -58,7 +62,7 @@ export const registerTrustedAppsRoutes = (router: SecuritySolutionPluginRouter) validate: GetTrustedAppsRequestSchema, options: { authRequired: true }, }, - getTrustedAppsListRouteHandler() + getTrustedAppsListRouteHandler(endpointAppContext) ); // CREATE @@ -68,7 +72,7 @@ export const registerTrustedAppsRoutes = (router: SecuritySolutionPluginRouter) validate: PostTrustedAppCreateRequestSchema, options: { authRequired: true }, }, - getTrustedAppsCreateRouteHandler() + getTrustedAppsCreateRouteHandler(endpointAppContext) ); // PUT @@ -88,6 +92,6 @@ export const registerTrustedAppsRoutes = (router: SecuritySolutionPluginRouter) validate: false, options: { authRequired: true }, }, - getTrustedAppsSummaryRouteHandler() + getTrustedAppsSummaryRouteHandler(endpointAppContext) ); }; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.test.ts index b0400e64778f0..42f4c6d157389 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.test.ts @@ -25,6 +25,7 @@ import { import { TrustedAppNotFoundError, TrustedAppVersionConflictError } from './errors'; import { toUpdateTrustedApp } from '../../../../common/endpoint/service/trusted_apps/to_update_trusted_app'; import { updateExceptionListItemImplementationMock } from './test_utils'; +import { ENDPOINT_TRUSTED_APPS_LIST_ID } from '../../../../../lists/common'; const exceptionsListClient = listMock.getExceptionListClient() as jest.Mocked; @@ -120,20 +121,41 @@ describe('service', () => { }); describe('getTrustedAppsList', () => { - it('should get trusted apps', async () => { + beforeEach(() => { exceptionsListClient.findExceptionListItem.mockResolvedValue({ data: [EXCEPTION_LIST_ITEM], page: 1, per_page: 20, total: 100, }); + }); + it('should get trusted apps', async () => { const result = await getTrustedAppsList(exceptionsListClient, { page: 1, per_page: 20 }); expect(result).toEqual({ data: [TRUSTED_APP], page: 1, per_page: 20, total: 100 }); expect(exceptionsListClient.createTrustedAppsList).toHaveBeenCalled(); }); + + it('should allow KQL to be defined', async () => { + const result = await getTrustedAppsList(exceptionsListClient, { + page: 1, + per_page: 20, + kuery: 'some-param.key: value', + }); + + expect(result).toEqual({ data: [TRUSTED_APP], page: 1, per_page: 20, total: 100 }); + expect(exceptionsListClient.findExceptionListItem).toHaveBeenCalledWith({ + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + page: 1, + perPage: 20, + filter: 'some-param.key: value', + namespaceType: 'agnostic', + sortField: 'name', + sortOrder: 'asc', + }); + }); }); describe('getTrustedAppsSummary', () => { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.ts index 486797659cca8..46bfe82c42037 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.ts @@ -65,7 +65,7 @@ export const getTrustedApp = async ( export const getTrustedAppsList = async ( exceptionsListClient: ExceptionListClient, - { page, per_page: perPage }: GetTrustedAppsListRequest + { page, per_page: perPage, kuery }: GetTrustedAppsListRequest ): Promise => { // Ensure list is created if it does not exist await exceptionsListClient.createTrustedAppsList(); @@ -74,7 +74,7 @@ export const getTrustedAppsList = async ( listId: ENDPOINT_TRUSTED_APPS_LIST_ID, page, perPage, - filter: undefined, + filter: kuery, namespaceType: 'agnostic', sortField: 'name', sortOrder: 'asc', diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 43096805544a1..9dfe4b6a7429c 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -202,7 +202,7 @@ export class Plugin implements IPlugin Date: Mon, 22 Mar 2021 15:06:43 +0100 Subject: [PATCH 11/31] Refactor schema with Put method after merging changes with master --- .../common/endpoint/schema/trusted_apps.ts | 62 +++++-------------- 1 file changed, 15 insertions(+), 47 deletions(-) diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts index f5ed41d3cdf93..26e20932ec7ec 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { schema, Type } from '@kbn/config-schema'; +import { schema } from '@kbn/config-schema'; import { ConditionEntry, ConditionEntryField, OperatingSystem } from '../types'; import { getDuplicateFields, isValidHash } from '../service/trusted_apps/validations'; @@ -47,18 +47,18 @@ const CommonEntrySchema = { schema.siblingRef('field'), ConditionEntryField.HASH, schema.string({ - validate: (hash) => + validate: (hash: string) => isValidHash(hash) ? undefined : `invalidField.${ConditionEntryField.HASH}`, }), schema.conditional( schema.siblingRef('field'), ConditionEntryField.PATH, schema.string({ - validate: (field) => + validate: (field: ConditionEntryField) => field.length > 0 ? undefined : `invalidField.${ConditionEntryField.PATH}`, }), schema.string({ - validate: (field) => + validate: (field: ConditionEntryField) => field.length > 0 ? undefined : `invalidField.${ConditionEntryField.SIGNER}`, }) ) @@ -106,7 +106,7 @@ const EntrySchemaDependingOnOS = schema.conditional( */ const EntriesSchema = schema.arrayOf(EntrySchemaDependingOnOS, { minSize: 1, - validate(entries) { + validate(entries: ConditionEntry[]) { return ( getDuplicateFields(entries) .map((field) => `duplicatedEntry.${field}`) @@ -114,14 +114,16 @@ const EntriesSchema = schema.arrayOf(EntrySchemaDependingOnOS, { ); }, }); -const createNewTrustedAppForOsScheme = ( - osSchema: Type, - entriesSchema: Type, - forUpdateFlow: boolean = false -) => + +const getTrustedAppForOsScheme = (forUpdateFlow: boolean = false) => schema.object({ name: schema.string({ minLength: 1, maxLength: 256 }), description: schema.maybe(schema.string({ minLength: 0, maxLength: 256, defaultValue: '' })), + os: schema.oneOf([ + schema.literal(OperatingSystem.WINDOWS), + schema.literal(OperatingSystem.LINUX), + schema.literal(OperatingSystem.MAC), + ]), effectScope: schema.oneOf([ schema.object({ type: schema.literal('global'), @@ -131,51 +133,17 @@ const createNewTrustedAppForOsScheme = `[${entryFieldLabels[field]}] field can only be used once`) - .join(', ') || undefined - ); - }, - }), + entries: EntriesSchema, ...(forUpdateFlow ? { version: schema.maybe(schema.string()) } : {}), }); export const PostTrustedAppCreateRequestSchema = { - body: schema.object({ - name: schema.string({ minLength: 1, maxLength: 256 }), - description: schema.maybe(schema.string({ minLength: 0, maxLength: 256, defaultValue: '' })), - os: schema.oneOf([ - schema.literal(OperatingSystem.WINDOWS), - schema.literal(OperatingSystem.LINUX), - schema.literal(OperatingSystem.MAC), - ]), - entries: EntriesSchema, - }), + body: getTrustedAppForOsScheme(), }; export const PutTrustedAppUpdateRequestSchema = { params: schema.object({ id: schema.string(), }), - body: schema.oneOf([ - createNewTrustedAppForOsScheme( - schema.oneOf([schema.literal(OperatingSystem.LINUX), schema.literal(OperatingSystem.MAC)]), - schema.oneOf([HashConditionEntrySchema, PathConditionEntrySchema]), - true - ), - createNewTrustedAppForOsScheme( - schema.literal(OperatingSystem.WINDOWS), - schema.oneOf([ - HashConditionEntrySchema, - PathConditionEntrySchema, - SignerConditionEntrySchema, - ]), - true - ), - ]), + body: getTrustedAppForOsScheme(true), }; From a3aba394550f1d38486b12771b9b5bb8ae991a3e Mon Sep 17 00:00:00 2001 From: David Sanchez Soler Date: Mon, 22 Mar 2021 17:02:00 +0100 Subject: [PATCH 12/31] WIP: allow effectScope only when feature flag is enabled --- .../endpoint/schema/trusted_apps.test.ts | 64 ++++++++++++++++++- .../common/endpoint/schema/trusted_apps.ts | 24 ++++--- 2 files changed, 77 insertions(+), 11 deletions(-) diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts index 74cfeb73d56e4..ed40518d0331d 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts @@ -5,8 +5,18 @@ * 2.0. */ -import { GetTrustedAppsRequestSchema, PostTrustedAppCreateRequestSchema } from './trusted_apps'; -import { ConditionEntry, ConditionEntryField, NewTrustedApp, OperatingSystem } from '../types'; +import { + GetTrustedAppsRequestSchema, + PostTrustedAppCreateRequestSchema, + PutTrustedAppUpdateRequestSchema, +} from './trusted_apps'; +import { + ConditionEntry, + ConditionEntryField, + NewTrustedApp, + OperatingSystem, + PutTrustedAppsRequestParams, +} from '../types'; describe('When invoking Trusted Apps Schema', () => { describe('for GET List', () => { @@ -330,4 +340,54 @@ describe('When invoking Trusted Apps Schema', () => { }); }); }); + + describe('for PUT Update', () => { + const createConditionEntry = (data?: T): ConditionEntry => ({ + field: ConditionEntryField.PATH, + type: 'match', + operator: 'included', + value: 'c:/programs files/Anti-Virus', + ...(data || {}), + }); + const createNewTrustedApp = (data?: T): NewTrustedApp => ({ + name: 'Some Anti-Virus App', + description: 'this one is ok', + os: OperatingSystem.WINDOWS, + effectScope: { type: 'global' }, + entries: [createConditionEntry()], + ...(data || {}), + }); + + const updateParams = (data?: T): PutTrustedAppsRequestParams => ({ + id: 'validId', + ...(data || {}), + }); + const body = PutTrustedAppUpdateRequestSchema.body; + const params = PutTrustedAppUpdateRequestSchema.params; + + it('should not error on a valid message', () => { + const bodyMsg = createNewTrustedApp(); + const paramsMsg = updateParams(); + expect(body.validate(bodyMsg)).toStrictEqual(bodyMsg); + expect(params.validate(paramsMsg)).toStrictEqual(paramsMsg); + }); + + it('should validate `id` params is required', () => { + expect(() => params.validate(updateParams({ id: undefined }))).toThrow(); + }); + + it('should validate `id` params to be string', () => { + expect(() => params.validate(updateParams({ id: 1 }))).toThrow(); + }); + + it('should validate `version`', () => { + const bodyMsg = createNewTrustedApp({ version: 'v1' }); + expect(body.validate(bodyMsg)).toStrictEqual(bodyMsg); + }); + + it('should validate `version` must be string', () => { + const bodyMsg = createNewTrustedApp({ version: 1 }); + expect(() => body.validate(bodyMsg)).toThrow(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts index 26e20932ec7ec..ac2c8151e1e0a 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts @@ -115,6 +115,8 @@ const EntriesSchema = schema.arrayOf(EntrySchemaDependingOnOS, { }, }); +const useArtifactsByPolicy: boolean = true; + const getTrustedAppForOsScheme = (forUpdateFlow: boolean = false) => schema.object({ name: schema.string({ minLength: 1, maxLength: 256 }), @@ -124,15 +126,19 @@ const getTrustedAppForOsScheme = (forUpdateFlow: boolean = false) => schema.literal(OperatingSystem.LINUX), schema.literal(OperatingSystem.MAC), ]), - effectScope: schema.oneOf([ - schema.object({ - type: schema.literal('global'), - }), - schema.object({ - type: schema.literal('policy'), - policies: schema.arrayOf(schema.string({ minLength: 1 })), - }), - ]), + ...(useArtifactsByPolicy + ? { + effectScope: schema.oneOf([ + schema.object({ + type: schema.literal('global'), + }), + schema.object({ + type: schema.literal('policy'), + policies: schema.arrayOf(schema.string({ minLength: 1 })), + }), + ]), + } + : {}), entries: EntriesSchema, ...(forUpdateFlow ? { version: schema.maybe(schema.string()) } : {}), }); From f4f3c890e9cbbf0976223b50f12ef200f826dbab Mon Sep 17 00:00:00 2001 From: David Sanchez Soler Date: Mon, 22 Mar 2021 17:02:22 +0100 Subject: [PATCH 13/31] Fixes errors with non declared logger --- .../server/endpoint/routes/trusted_apps/handlers.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.ts index 3122b2b570716..b9f3daf6c83c4 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.ts @@ -18,6 +18,7 @@ import { PutTrustedAppsRequestParams, PutTrustedAppUpdateRequest, } from '../../../../common/endpoint/types'; +import { EndpointAppContext } from '../../types'; import { createTrustedApp, @@ -68,6 +69,8 @@ export const getTrustedAppsDeleteRouteHandler = ( unknown, SecuritySolutionRequestHandlerContext > => { + const logger = endpointAppContext.logFactory.get('trusted_apps'); + return async (context, req, res) => { try { await deleteTrustedApp(exceptionListClientFromContext(context), req.params); @@ -108,6 +111,8 @@ export const getTrustedAppsListRouteHandler = ( unknown, SecuritySolutionRequestHandlerContext > => { + const logger = endpointAppContext.logFactory.get('trusted_apps'); + return async (context, req, res) => { try { return res.ok({ @@ -119,12 +124,16 @@ export const getTrustedAppsListRouteHandler = ( }; }; -export const getTrustedAppsCreateRouteHandler = (): RequestHandler< +export const getTrustedAppsCreateRouteHandler = ( + endpointAppContext: EndpointAppContext +): RequestHandler< unknown, unknown, PostTrustedAppCreateRequest, SecuritySolutionRequestHandlerContext > => { + const logger = endpointAppContext.logFactory.get('trusted_apps'); + return async (context, req, res) => { try { return res.ok({ @@ -144,6 +153,8 @@ export const getTrustedAppsUpdateRouteHandler = ( PutTrustedAppUpdateRequest, SecuritySolutionRequestHandlerContext > => { + const logger = endpointAppContext.logFactory.get('trusted_apps'); + return async (context, req, res) => { try { return res.ok({ From f5305af30860e03289fa244b4d3a9f7d5fb3164f Mon Sep 17 00:00:00 2001 From: David Sanchez Soler Date: Mon, 22 Mar 2021 17:45:27 +0100 Subject: [PATCH 14/31] Uses experimental features module to allow or not effectScope on create/update trusted app schema --- .../endpoint/schema/trusted_apps.test.ts | 20 +++++++---- .../common/endpoint/schema/trusted_apps.ts | 34 ++++++++++--------- .../common/experimental_features.ts | 1 + .../endpoint/routes/trusted_apps/index.ts | 26 ++++++++------ 4 files changed, 47 insertions(+), 34 deletions(-) diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts index ed40518d0331d..2fb17fe5cb106 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts @@ -6,9 +6,9 @@ */ import { - GetTrustedAppsRequestSchema, - PostTrustedAppCreateRequestSchema, - PutTrustedAppUpdateRequestSchema, + getGetTrustedAppsRequestSchema, + getPostTrustedAppCreateRequestSchema, + getPutTrustedAppUpdateRequestSchema, } from './trusted_apps'; import { ConditionEntry, @@ -17,6 +17,12 @@ import { OperatingSystem, PutTrustedAppsRequestParams, } from '../types'; +import { ExperimentalFeatures } from '../../experimental_features'; + +const experimentalValues: ExperimentalFeatures = { + fleetServerEnabled: false, + trustedAppsByPolicyEnabled: true, +}; describe('When invoking Trusted Apps Schema', () => { describe('for GET List', () => { @@ -24,7 +30,7 @@ describe('When invoking Trusted Apps Schema', () => { page, per_page: perPage, }); - const query = GetTrustedAppsRequestSchema.query; + const query = getGetTrustedAppsRequestSchema(experimentalValues).query; describe('query param validation', () => { it('should return query params if valid', () => { @@ -97,7 +103,7 @@ describe('When invoking Trusted Apps Schema', () => { entries: [createConditionEntry()], ...(data || {}), }); - const body = PostTrustedAppCreateRequestSchema.body; + const body = getPostTrustedAppCreateRequestSchema(experimentalValues).body; it('should not error on a valid message', () => { const bodyMsg = createNewTrustedApp(); @@ -362,8 +368,8 @@ describe('When invoking Trusted Apps Schema', () => { id: 'validId', ...(data || {}), }); - const body = PutTrustedAppUpdateRequestSchema.body; - const params = PutTrustedAppUpdateRequestSchema.params; + const body = getPutTrustedAppUpdateRequestSchema(experimentalValues).body; + const params = getPutTrustedAppUpdateRequestSchema(experimentalValues).params; it('should not error on a valid message', () => { const bodyMsg = createNewTrustedApp(); diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts index ac2c8151e1e0a..3c1c115a6ff2d 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts @@ -8,26 +8,27 @@ import { schema } from '@kbn/config-schema'; import { ConditionEntry, ConditionEntryField, OperatingSystem } from '../types'; import { getDuplicateFields, isValidHash } from '../service/trusted_apps/validations'; +import { ExperimentalFeatures } from '../../experimental_features'; -export const DeleteTrustedAppsRequestSchema = { +export const getDeleteTrustedAppsRequestSchema = (experimentalValues: ExperimentalFeatures) => ({ params: schema.object({ id: schema.string(), }), -}; +}); -export const GetOneTrustedAppRequestSchema = { +export const getGetOneTrustedAppRequestSchema = (experimentalValues: ExperimentalFeatures) => ({ params: schema.object({ id: schema.string(), }), -}; +}); -export const GetTrustedAppsRequestSchema = { +export const getGetTrustedAppsRequestSchema = (experimentalValues: ExperimentalFeatures) => ({ query: schema.object({ page: schema.maybe(schema.number({ defaultValue: 1, min: 1 })), per_page: schema.maybe(schema.number({ defaultValue: 20, min: 1 })), kuery: schema.maybe(schema.string()), }), -}; +}); const ConditionEntryTypeSchema = schema.literal('match'); const ConditionEntryOperatorSchema = schema.literal('included'); @@ -115,9 +116,10 @@ const EntriesSchema = schema.arrayOf(EntrySchemaDependingOnOS, { }, }); -const useArtifactsByPolicy: boolean = true; - -const getTrustedAppForOsScheme = (forUpdateFlow: boolean = false) => +const getTrustedAppForOsScheme = ( + experimentalValues: ExperimentalFeatures, + forUpdateFlow: boolean = false +) => schema.object({ name: schema.string({ minLength: 1, maxLength: 256 }), description: schema.maybe(schema.string({ minLength: 0, maxLength: 256, defaultValue: '' })), @@ -126,7 +128,7 @@ const getTrustedAppForOsScheme = (forUpdateFlow: boolean = false) => schema.literal(OperatingSystem.LINUX), schema.literal(OperatingSystem.MAC), ]), - ...(useArtifactsByPolicy + ...(experimentalValues.trustedAppsByPolicyEnabled ? { effectScope: schema.oneOf([ schema.object({ @@ -143,13 +145,13 @@ const getTrustedAppForOsScheme = (forUpdateFlow: boolean = false) => ...(forUpdateFlow ? { version: schema.maybe(schema.string()) } : {}), }); -export const PostTrustedAppCreateRequestSchema = { - body: getTrustedAppForOsScheme(), -}; +export const getPostTrustedAppCreateRequestSchema = (experimentalValues: ExperimentalFeatures) => ({ + body: getTrustedAppForOsScheme(experimentalValues), +}); -export const PutTrustedAppUpdateRequestSchema = { +export const getPutTrustedAppUpdateRequestSchema = (experimentalValues: ExperimentalFeatures) => ({ params: schema.object({ id: schema.string(), }), - body: getTrustedAppForOsScheme(true), -}; + body: getTrustedAppForOsScheme(experimentalValues, true), +}); diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index c764c31a2d781..19de81cb95c3f 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -13,6 +13,7 @@ export type ExperimentalFeatures = typeof allowedExperimentalValues; */ const allowedExperimentalValues = Object.freeze({ fleetServerEnabled: false, + trustedAppsByPolicyEnabled: false, }); type ExperimentalConfigKeys = Array; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/index.ts index 458fa8e36d3da..42d6a381ca8d3 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/index.ts @@ -6,11 +6,11 @@ */ import { - DeleteTrustedAppsRequestSchema, - GetOneTrustedAppRequestSchema, - GetTrustedAppsRequestSchema, - PostTrustedAppCreateRequestSchema, - PutTrustedAppUpdateRequestSchema, + getDeleteTrustedAppsRequestSchema, + getGetOneTrustedAppRequestSchema, + getGetTrustedAppsRequestSchema, + getPostTrustedAppCreateRequestSchema, + getPutTrustedAppUpdateRequestSchema, } from '../../../../common/endpoint/schema/trusted_apps'; import { TRUSTED_APPS_CREATE_API, @@ -20,6 +20,8 @@ import { TRUSTED_APPS_UPDATE_API, TRUSTED_APPS_SUMMARY_API, } from '../../../../common/endpoint/constants'; +import { parseExperimentalConfigValue } from '../../../../common/experimental_features'; + import { getTrustedAppsCreateRouteHandler, getTrustedAppsDeleteRouteHandler, @@ -31,15 +33,17 @@ import { import { SecuritySolutionPluginRouter } from '../../../types'; import { EndpointAppContext } from '../../types'; -export const registerTrustedAppsRoutes = ( +export const registerTrustedAppsRoutes = async ( router: SecuritySolutionPluginRouter, endpointAppContext: EndpointAppContext ) => { + const config = await endpointAppContext.config(); + const experimentalValues = parseExperimentalConfigValue(config.enableExperimental); // DELETE one router.delete( { path: TRUSTED_APPS_DELETE_API, - validate: DeleteTrustedAppsRequestSchema, + validate: getDeleteTrustedAppsRequestSchema(experimentalValues), options: { authRequired: true }, }, getTrustedAppsDeleteRouteHandler(endpointAppContext) @@ -49,7 +53,7 @@ export const registerTrustedAppsRoutes = ( router.get( { path: TRUSTED_APPS_GET_API, - validate: GetOneTrustedAppRequestSchema, + validate: getGetOneTrustedAppRequestSchema(experimentalValues), options: { authRequired: true }, }, getTrustedAppsGetOneHandler(endpointAppContext) @@ -59,7 +63,7 @@ export const registerTrustedAppsRoutes = ( router.get( { path: TRUSTED_APPS_LIST_API, - validate: GetTrustedAppsRequestSchema, + validate: getGetTrustedAppsRequestSchema(experimentalValues), options: { authRequired: true }, }, getTrustedAppsListRouteHandler(endpointAppContext) @@ -69,7 +73,7 @@ export const registerTrustedAppsRoutes = ( router.post( { path: TRUSTED_APPS_CREATE_API, - validate: PostTrustedAppCreateRequestSchema, + validate: getPostTrustedAppCreateRequestSchema(experimentalValues), options: { authRequired: true }, }, getTrustedAppsCreateRouteHandler(endpointAppContext) @@ -79,7 +83,7 @@ export const registerTrustedAppsRoutes = ( router.put( { path: TRUSTED_APPS_UPDATE_API, - validate: PutTrustedAppUpdateRequestSchema, + validate: getPutTrustedAppUpdateRequestSchema(experimentalValues), options: { authRequired: true }, }, getTrustedAppsUpdateRouteHandler(endpointAppContext) From e9f0bd447587f46a3beef221bbad355986000b55 Mon Sep 17 00:00:00 2001 From: David Sanchez Soler Date: Tue, 23 Mar 2021 16:22:31 +0100 Subject: [PATCH 15/31] Set default value for effectScope when feature flag is disabled --- .../endpoint/schema/trusted_apps.test.ts | 21 +++---- .../common/endpoint/schema/trusted_apps.ts | 56 ++++++++----------- .../common/endpoint/types/trusted_apps.ts | 3 +- .../endpoint/routes/trusted_apps/handlers.ts | 27 ++++++--- .../endpoint/routes/trusted_apps/index.ts | 25 ++++----- .../endpoint/routes/trusted_apps/service.ts | 6 +- .../plugins/security_solution/server/index.ts | 3 + 7 files changed, 72 insertions(+), 69 deletions(-) diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts index 2fb17fe5cb106..326795ae55662 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts @@ -6,9 +6,9 @@ */ import { - getGetTrustedAppsRequestSchema, - getPostTrustedAppCreateRequestSchema, - getPutTrustedAppUpdateRequestSchema, + GetTrustedAppsRequestSchema, + PostTrustedAppCreateRequestSchema, + PutTrustedAppUpdateRequestSchema, } from './trusted_apps'; import { ConditionEntry, @@ -17,12 +17,6 @@ import { OperatingSystem, PutTrustedAppsRequestParams, } from '../types'; -import { ExperimentalFeatures } from '../../experimental_features'; - -const experimentalValues: ExperimentalFeatures = { - fleetServerEnabled: false, - trustedAppsByPolicyEnabled: true, -}; describe('When invoking Trusted Apps Schema', () => { describe('for GET List', () => { @@ -30,7 +24,7 @@ describe('When invoking Trusted Apps Schema', () => { page, per_page: perPage, }); - const query = getGetTrustedAppsRequestSchema(experimentalValues).query; + const query = GetTrustedAppsRequestSchema.query; describe('query param validation', () => { it('should return query params if valid', () => { @@ -103,7 +97,7 @@ describe('When invoking Trusted Apps Schema', () => { entries: [createConditionEntry()], ...(data || {}), }); - const body = getPostTrustedAppCreateRequestSchema(experimentalValues).body; + const body = PostTrustedAppCreateRequestSchema.body; it('should not error on a valid message', () => { const bodyMsg = createNewTrustedApp(); @@ -368,8 +362,9 @@ describe('When invoking Trusted Apps Schema', () => { id: 'validId', ...(data || {}), }); - const body = getPutTrustedAppUpdateRequestSchema(experimentalValues).body; - const params = getPutTrustedAppUpdateRequestSchema(experimentalValues).params; + + const body = PutTrustedAppUpdateRequestSchema.body; + const params = PutTrustedAppUpdateRequestSchema.params; it('should not error on a valid message', () => { const bodyMsg = createNewTrustedApp(); diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts index 3c1c115a6ff2d..e582744e1a141 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts @@ -8,27 +8,26 @@ import { schema } from '@kbn/config-schema'; import { ConditionEntry, ConditionEntryField, OperatingSystem } from '../types'; import { getDuplicateFields, isValidHash } from '../service/trusted_apps/validations'; -import { ExperimentalFeatures } from '../../experimental_features'; -export const getDeleteTrustedAppsRequestSchema = (experimentalValues: ExperimentalFeatures) => ({ +export const DeleteTrustedAppsRequestSchema = { params: schema.object({ id: schema.string(), }), -}); +}; -export const getGetOneTrustedAppRequestSchema = (experimentalValues: ExperimentalFeatures) => ({ +export const GetOneTrustedAppRequestSchema = { params: schema.object({ id: schema.string(), }), -}); +}; -export const getGetTrustedAppsRequestSchema = (experimentalValues: ExperimentalFeatures) => ({ +export const GetTrustedAppsRequestSchema = { query: schema.object({ page: schema.maybe(schema.number({ defaultValue: 1, min: 1 })), per_page: schema.maybe(schema.number({ defaultValue: 20, min: 1 })), kuery: schema.maybe(schema.string()), }), -}); +}; const ConditionEntryTypeSchema = schema.literal('match'); const ConditionEntryOperatorSchema = schema.literal('included'); @@ -55,11 +54,11 @@ const CommonEntrySchema = { schema.siblingRef('field'), ConditionEntryField.PATH, schema.string({ - validate: (field: ConditionEntryField) => + validate: (field: string) => field.length > 0 ? undefined : `invalidField.${ConditionEntryField.PATH}`, }), schema.string({ - validate: (field: ConditionEntryField) => + validate: (field: string) => field.length > 0 ? undefined : `invalidField.${ConditionEntryField.SIGNER}`, }) ) @@ -116,10 +115,7 @@ const EntriesSchema = schema.arrayOf(EntrySchemaDependingOnOS, { }, }); -const getTrustedAppForOsScheme = ( - experimentalValues: ExperimentalFeatures, - forUpdateFlow: boolean = false -) => +const getTrustedAppForOsScheme = (forUpdateFlow: boolean = false) => schema.object({ name: schema.string({ minLength: 1, maxLength: 256 }), description: schema.maybe(schema.string({ minLength: 0, maxLength: 256, defaultValue: '' })), @@ -128,30 +124,26 @@ const getTrustedAppForOsScheme = ( schema.literal(OperatingSystem.LINUX), schema.literal(OperatingSystem.MAC), ]), - ...(experimentalValues.trustedAppsByPolicyEnabled - ? { - effectScope: schema.oneOf([ - schema.object({ - type: schema.literal('global'), - }), - schema.object({ - type: schema.literal('policy'), - policies: schema.arrayOf(schema.string({ minLength: 1 })), - }), - ]), - } - : {}), + effectScope: schema.oneOf([ + schema.object({ + type: schema.literal('global'), + }), + schema.object({ + type: schema.literal('policy'), + policies: schema.arrayOf(schema.string({ minLength: 1 })), + }), + ]), entries: EntriesSchema, ...(forUpdateFlow ? { version: schema.maybe(schema.string()) } : {}), }); -export const getPostTrustedAppCreateRequestSchema = (experimentalValues: ExperimentalFeatures) => ({ - body: getTrustedAppForOsScheme(experimentalValues), -}); +export const PostTrustedAppCreateRequestSchema = { + body: getTrustedAppForOsScheme(), +}; -export const getPutTrustedAppUpdateRequestSchema = (experimentalValues: ExperimentalFeatures) => ({ +export const PutTrustedAppUpdateRequestSchema = { params: schema.object({ id: schema.string(), }), - body: getTrustedAppForOsScheme(experimentalValues, true), -}); + body: getTrustedAppForOsScheme(true), +}; diff --git a/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts index 898da33c00f7c..d36958c11d2a1 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts @@ -51,7 +51,8 @@ export interface PostTrustedAppCreateResponse { export type PutTrustedAppsRequestParams = TypeOf; /** API Request body for Updating a new Trusted App entry */ -export type PutTrustedAppUpdateRequest = TypeOf; +export type PutTrustedAppUpdateRequest = TypeOf & + (MacosLinuxConditionEntries | WindowsConditionEntries); export type PutTrustedAppUpdateResponse = PostTrustedAppCreateResponse; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.ts index b9f3daf6c83c4..bb794c927e8bc 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.ts @@ -18,6 +18,7 @@ import { PutTrustedAppsRequestParams, PutTrustedAppUpdateRequest, } from '../../../../common/endpoint/types'; +import { parseExperimentalConfigValue } from '../../../../common/experimental_features'; import { EndpointAppContext } from '../../types'; import { @@ -30,6 +31,19 @@ import { } from './service'; import { TrustedAppNotFoundError, TrustedAppVersionConflictError } from './errors'; +const getBodyAfterFeatureFlagCheck = async ( + body: PutTrustedAppUpdateRequest | PostTrustedAppCreateRequest, + endpointAppContext: EndpointAppContext +): Promise => { + const config = await endpointAppContext.config(); + const isTrustedAppsByPolicyEnabled = parseExperimentalConfigValue(config.enableExperimental) + .trustedAppsByPolicyEnabled; + return { + ...body, + ...(isTrustedAppsByPolicyEnabled ? body.effectScope : { effectSctope: { type: 'policy:all' } }), + }; +}; + const exceptionListClientFromContext = ( context: SecuritySolutionRequestHandlerContext ): ExceptionListClient => { @@ -133,11 +147,12 @@ export const getTrustedAppsCreateRouteHandler = ( SecuritySolutionRequestHandlerContext > => { const logger = endpointAppContext.logFactory.get('trusted_apps'); - return async (context, req, res) => { try { + const body = await getBodyAfterFeatureFlagCheck(req.body, endpointAppContext); + return res.ok({ - body: await createTrustedApp(exceptionListClientFromContext(context), req.body), + body: await createTrustedApp(exceptionListClientFromContext(context), body), }); } catch (error) { return errorHandler(logger, res, error); @@ -157,12 +172,10 @@ export const getTrustedAppsUpdateRouteHandler = ( return async (context, req, res) => { try { + const body = await getBodyAfterFeatureFlagCheck(req.body, endpointAppContext); + return res.ok({ - body: await updateTrustedApp( - exceptionListClientFromContext(context), - req.params.id, - req.body - ), + body: await updateTrustedApp(exceptionListClientFromContext(context), req.params.id, body), }); } catch (error) { return errorHandler(logger, res, error); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/index.ts index 42d6a381ca8d3..4e61f14408f47 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/index.ts @@ -6,11 +6,11 @@ */ import { - getDeleteTrustedAppsRequestSchema, - getGetOneTrustedAppRequestSchema, - getGetTrustedAppsRequestSchema, - getPostTrustedAppCreateRequestSchema, - getPutTrustedAppUpdateRequestSchema, + DeleteTrustedAppsRequestSchema, + GetOneTrustedAppRequestSchema, + GetTrustedAppsRequestSchema, + PostTrustedAppCreateRequestSchema, + PutTrustedAppUpdateRequestSchema, } from '../../../../common/endpoint/schema/trusted_apps'; import { TRUSTED_APPS_CREATE_API, @@ -20,7 +20,6 @@ import { TRUSTED_APPS_UPDATE_API, TRUSTED_APPS_SUMMARY_API, } from '../../../../common/endpoint/constants'; -import { parseExperimentalConfigValue } from '../../../../common/experimental_features'; import { getTrustedAppsCreateRouteHandler, @@ -33,17 +32,15 @@ import { import { SecuritySolutionPluginRouter } from '../../../types'; import { EndpointAppContext } from '../../types'; -export const registerTrustedAppsRoutes = async ( +export const registerTrustedAppsRoutes = ( router: SecuritySolutionPluginRouter, endpointAppContext: EndpointAppContext ) => { - const config = await endpointAppContext.config(); - const experimentalValues = parseExperimentalConfigValue(config.enableExperimental); // DELETE one router.delete( { path: TRUSTED_APPS_DELETE_API, - validate: getDeleteTrustedAppsRequestSchema(experimentalValues), + validate: DeleteTrustedAppsRequestSchema, options: { authRequired: true }, }, getTrustedAppsDeleteRouteHandler(endpointAppContext) @@ -53,7 +50,7 @@ export const registerTrustedAppsRoutes = async ( router.get( { path: TRUSTED_APPS_GET_API, - validate: getGetOneTrustedAppRequestSchema(experimentalValues), + validate: GetOneTrustedAppRequestSchema, options: { authRequired: true }, }, getTrustedAppsGetOneHandler(endpointAppContext) @@ -63,7 +60,7 @@ export const registerTrustedAppsRoutes = async ( router.get( { path: TRUSTED_APPS_LIST_API, - validate: getGetTrustedAppsRequestSchema(experimentalValues), + validate: GetTrustedAppsRequestSchema, options: { authRequired: true }, }, getTrustedAppsListRouteHandler(endpointAppContext) @@ -73,7 +70,7 @@ export const registerTrustedAppsRoutes = async ( router.post( { path: TRUSTED_APPS_CREATE_API, - validate: getPostTrustedAppCreateRequestSchema(experimentalValues), + validate: PostTrustedAppCreateRequestSchema, options: { authRequired: true }, }, getTrustedAppsCreateRouteHandler(endpointAppContext) @@ -83,7 +80,7 @@ export const registerTrustedAppsRoutes = async ( router.put( { path: TRUSTED_APPS_UPDATE_API, - validate: getPutTrustedAppUpdateRequestSchema(experimentalValues), + validate: PutTrustedAppUpdateRequestSchema, options: { authRequired: true }, }, getTrustedAppsUpdateRouteHandler(endpointAppContext) diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.ts index 46bfe82c42037..a2d79f7246b14 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.ts @@ -6,7 +6,10 @@ */ import { ExceptionListClient } from '../../../../../lists/server'; -import { ENDPOINT_TRUSTED_APPS_LIST_ID } from '../../../../../lists/common'; +import { + ENDPOINT_TRUSTED_APPS_LIST_ID, + ExceptionListItemSchema, +} from '../../../../../lists/common'; import { DeleteTrustedAppsRequestParams, @@ -26,7 +29,6 @@ import { osFromExceptionItem, updatedTrustedAppToUpdateExceptionListItemOptions, } from './mapping'; -import { ExceptionListItemSchema } from '../../../../../lists/common'; import { TrustedAppNotFoundError, TrustedAppVersionConflictError } from './errors'; export const deleteTrustedApp = async ( diff --git a/x-pack/plugins/security_solution/server/index.ts b/x-pack/plugins/security_solution/server/index.ts index be3f5109af019..a4b9dddec812e 100644 --- a/x-pack/plugins/security_solution/server/index.ts +++ b/x-pack/plugins/security_solution/server/index.ts @@ -16,6 +16,9 @@ export const plugin = (context: PluginInitializerContext) => { }; export const config: PluginConfigDescriptor = { + exposeToBrowser: { + enableExperimental: true, + }, schema: configSchema, deprecations: ({ renameFromRoot }) => [ renameFromRoot('xpack.siem.enabled', 'xpack.securitySolution.enabled'), From cf74b2b202061e4e8a38d611990986047fefdaab Mon Sep 17 00:00:00 2001 From: David Sanchez Soler Date: Tue, 23 Mar 2021 16:23:33 +0100 Subject: [PATCH 16/31] Adds experimentals into redux store. Also creates hook to retrieve a feature flag value from state --- .../hooks/use_experimental_features.tsx | 22 +++++++++++++++++++ .../public/common/store/app/model.ts | 2 ++ .../public/common/store/reducer.test.ts | 3 +++ .../public/common/store/reducer.ts | 5 ++++- .../security_solution/public/common/types.ts | 4 ++++ .../security_solution/public/plugin.tsx | 8 ++++++- 6 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.tsx diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.tsx b/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.tsx new file mode 100644 index 0000000000000..7987f3683b57e --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.tsx @@ -0,0 +1,22 @@ +/* + * 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 { useSelector } from 'react-redux'; +import { State } from '../../common/store'; +import { Immutable } from '../../../common/endpoint/types'; +import { ExperimentalFeatures } from '../../../common/experimental_features'; + +const getExperimentalFeatures = ( + state: Immutable | undefined, + feature: keyof ExperimentalFeatures +): boolean => (state ? state[feature] : false); + +export const useIsExperimentalFeatureEnabled = (feature: keyof ExperimentalFeatures): boolean => { + return useSelector((state: State) => + getExperimentalFeatures(state.app.enableExperimental, feature) + ); +}; diff --git a/x-pack/plugins/security_solution/public/common/store/app/model.ts b/x-pack/plugins/security_solution/public/common/store/app/model.ts index 38ecedc0c7ba7..5a252e4aa48f2 100644 --- a/x-pack/plugins/security_solution/public/common/store/app/model.ts +++ b/x-pack/plugins/security_solution/public/common/store/app/model.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { ExperimentalFeatures } from '../../../../common/experimental_features'; import { Note } from '../../lib/note'; export type ErrorState = ErrorModel; @@ -24,4 +25,5 @@ export type ErrorModel = Error[]; export interface AppModel { notesById: NotesById; errors: ErrorState; + enableExperimental?: ExperimentalFeatures; } diff --git a/x-pack/plugins/security_solution/public/common/store/reducer.test.ts b/x-pack/plugins/security_solution/public/common/store/reducer.test.ts index 9a2289765e85d..d2808a02c8621 100644 --- a/x-pack/plugins/security_solution/public/common/store/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/common/store/reducer.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { parseExperimentalConfigValue } from '../../..//common/experimental_features'; import { createInitialState } from './reducer'; jest.mock('../lib/kibana', () => ({ @@ -22,6 +23,7 @@ describe('createInitialState', () => { kibanaIndexPatterns: [{ id: '1234567890987654321', title: 'mock-kibana' }], configIndexPatterns: ['auditbeat-*', 'filebeat'], signalIndexName: 'siem-signals-default', + enableExperimental: parseExperimentalConfigValue([]), } ); @@ -35,6 +37,7 @@ describe('createInitialState', () => { kibanaIndexPatterns: [{ id: '1234567890987654321', title: 'mock-kibana' }], configIndexPatterns: [], signalIndexName: 'siem-signals-default', + enableExperimental: parseExperimentalConfigValue([]), } ); diff --git a/x-pack/plugins/security_solution/public/common/store/reducer.ts b/x-pack/plugins/security_solution/public/common/store/reducer.ts index 27fddafc3781f..c2ef2563fe63e 100644 --- a/x-pack/plugins/security_solution/public/common/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/common/store/reducer.ts @@ -21,6 +21,7 @@ import { ManagementPluginReducer } from '../../management'; import { State } from './types'; import { AppAction } from './actions'; import { KibanaIndexPatterns } from './sourcerer/model'; +import { ExperimentalFeatures } from '../../../common/experimental_features'; export type SubPluginsInitReducer = HostsPluginReducer & NetworkPluginReducer & @@ -36,14 +37,16 @@ export const createInitialState = ( kibanaIndexPatterns, configIndexPatterns, signalIndexName, + enableExperimental, }: { kibanaIndexPatterns: KibanaIndexPatterns; configIndexPatterns: string[]; signalIndexName: string | null; + enableExperimental: ExperimentalFeatures; } ): PreloadedState => { const preloadedState: PreloadedState = { - app: initialAppState, + app: { ...initialAppState, enableExperimental }, dragAndDrop: initialDragAndDropState, ...pluginsInitState, inputs: createInitialInputsState(), diff --git a/x-pack/plugins/security_solution/public/common/types.ts b/x-pack/plugins/security_solution/public/common/types.ts index 68346847eb8d1..c6dc24213a71f 100644 --- a/x-pack/plugins/security_solution/public/common/types.ts +++ b/x-pack/plugins/security_solution/public/common/types.ts @@ -10,3 +10,7 @@ export interface ServerApiError { error: string; message: string; } + +export interface SecuritySolutionConfigType { + enableExperimental: string[]; +} diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index 6aaad4a157191..9f9bfaac2b227 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -65,14 +65,19 @@ import { import { SecurityAppStore } from './common/store/store'; import { getCaseConnectorUI } from './cases/components/connectors'; import { licenseService } from './common/hooks/use_license'; +import { SecuritySolutionConfigType } from './common/types'; + import { getLazyEndpointPolicyEditExtension } from './management/pages/policy/view/ingest_manager_integration/lazy_endpoint_policy_edit_extension'; import { LazyEndpointPolicyCreateExtension } from './management/pages/policy/view/ingest_manager_integration/lazy_endpoint_policy_create_extension'; import { getLazyEndpointPackageCustomExtension } from './management/pages/policy/view/ingest_manager_integration/lazy_endpoint_package_custom_extension'; +import { parseExperimentalConfigValue } from '../common/experimental_features'; export class Plugin implements IPlugin { private kibanaVersion: string; + private config: SecuritySolutionConfigType; - constructor(initializerContext: PluginInitializerContext) { + constructor(private readonly initializerContext: PluginInitializerContext) { + this.config = this.initializerContext.config.get(); this.kibanaVersion = initializerContext.env.packageInfo.version; } private detectionsUpdater$ = new Subject(); @@ -520,6 +525,7 @@ export class Plugin implements IPlugin Date: Tue, 23 Mar 2021 16:24:27 +0100 Subject: [PATCH 17/31] Hides effectPolicy when feature flag is not enabled --- .../components/create_trusted_app_form.tsx | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx index 3888aedd75130..19b58463bac13 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx @@ -27,6 +27,7 @@ import { } from '../../../../../../common/endpoint/types'; import { isValidHash } from '../../../../../../common/endpoint/validation/trusted_apps'; +import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; import { isGlobalEffectScope, isMacosLinuxTrustedAppCondition, @@ -183,6 +184,10 @@ export const CreateTrustedAppForm = memo( const dataTestSubj = formProps['data-test-subj']; + const isTrustedAppsByPolicyEnabled = useIsExperimentalFeatureEnabled( + 'trustedAppsByPolicyEnabled' + ); + const osOptions: Array> = useMemo( () => OPERATING_SYSTEMS.map((os) => ({ value: os, inputDisplay: OS_TITLES[os] })), [] @@ -500,16 +505,18 @@ export const CreateTrustedAppForm = memo( - - - + {isTrustedAppsByPolicyEnabled ? ( + + + + ) : null} ); } From 530db6d7d4c09418567c4230deee6ee127e98f6a Mon Sep 17 00:00:00 2001 From: David Sanchez Soler Date: Tue, 23 Mar 2021 17:39:54 +0100 Subject: [PATCH 18/31] Fixes unit test mocking hook and adds new test case --- .../components/create_trusted_app_form.test.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx index 0d9abcdee96b4..201b387440439 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx @@ -23,6 +23,10 @@ import { CreateTrustedAppForm, CreateTrustedAppFormProps } from './create_truste import { defaultNewTrustedApp } from '../../store/builders'; import { forceHTMLElementOffsetWidth } from './effected_policy_select/test_utils'; import { EndpointDocGenerator } from '../../../../../../common/endpoint/generate_data'; +import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; + +jest.mock('../../../../../common/hooks/use_experimental_features'); +const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock; describe('When using the Trusted App Form', () => { const dataTestSubjForForm = 'createForm'; @@ -109,6 +113,7 @@ describe('When using the Trusted App Form', () => { beforeEach(() => { resetHTMLElementOffsetWidth = forceHTMLElementOffsetWidth(); + useIsExperimentalFeatureEnabledMock.mockReturnValue(true); mockedContext = createAppRootMockRenderer(); @@ -294,6 +299,16 @@ describe('When using the Trusted App Form', () => { }); }); + describe('the Policy Selection area under feature flag', () => { + it("shouldn't display the policiy selection area ", () => { + useIsExperimentalFeatureEnabledMock.mockReturnValue(false); + render(); + expect( + renderResult.queryByText('Apply trusted application globally') + ).not.toBeInTheDocument(); + }); + }); + describe('and the user visits required fields but does not fill them out', () => { beforeEach(() => { render(); From 99565d87b007eb2e0e5a38ed2cccd44f6cf16ce3 Mon Sep 17 00:00:00 2001 From: David Sanchez Soler Date: Wed, 24 Mar 2021 09:53:23 +0100 Subject: [PATCH 19/31] Changes file extension for custom hook --- ...ental_features.tsx => use_experimental_features.ts} | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) rename x-pack/plugins/security_solution/public/common/hooks/{use_experimental_features.tsx => use_experimental_features.ts} (60%) diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.tsx b/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.ts similarity index 60% rename from x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.tsx rename to x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.ts index 7987f3683b57e..43ccf4b291a7a 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.tsx +++ b/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.ts @@ -7,16 +7,10 @@ import { useSelector } from 'react-redux'; import { State } from '../../common/store'; -import { Immutable } from '../../../common/endpoint/types'; import { ExperimentalFeatures } from '../../../common/experimental_features'; -const getExperimentalFeatures = ( - state: Immutable | undefined, - feature: keyof ExperimentalFeatures -): boolean => (state ? state[feature] : false); - export const useIsExperimentalFeatureEnabled = (feature: keyof ExperimentalFeatures): boolean => { - return useSelector((state: State) => - getExperimentalFeatures(state.app.enableExperimental, feature) + return useSelector(({ app: { enableExperimental } }: State) => + enableExperimental ? enableExperimental[feature] : false ); }; From 4a4cafc965907b5875ec935e8bed7ec8f195c892 Mon Sep 17 00:00:00 2001 From: David Sanchez Soler Date: Wed, 24 Mar 2021 09:53:45 +0100 Subject: [PATCH 20/31] Adds new unit test for custom hook --- .../hooks/use_experimental_features.test.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.test.ts diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.test.ts b/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.test.ts new file mode 100644 index 0000000000000..bb749f0b8c674 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.test.ts @@ -0,0 +1,49 @@ +/* + * 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 { useSelector } from 'react-redux'; +import { ExperimentalFeatures } from '../../../common/experimental_features'; +import { useIsExperimentalFeatureEnabled } from './use_experimental_features'; + +jest.mock('react-redux'); +const useSelectorMock = useSelector as jest.Mock; +const mockAppState = { + app: { + enableExperimental: { + featureA: true, + featureB: false, + }, + }, +}; + +describe('useExperimentalFeatures', () => { + beforeEach(() => { + useSelectorMock.mockImplementation((cb) => { + return cb(mockAppState); + }); + }); + afterEach(() => { + useSelectorMock.mockClear(); + }); + it('returns false when unexisting feature', async () => { + const result = useIsExperimentalFeatureEnabled( + 'unexistingFeature' as keyof ExperimentalFeatures + ); + + expect(result).toBeFalsy(); + }); + it('returns true when existing feature and is enabled', async () => { + const result = useIsExperimentalFeatureEnabled('featureA' as keyof ExperimentalFeatures); + + expect(result).toBeTruthy(); + }); + it('returns false when existing feature and is disabled', async () => { + const result = useIsExperimentalFeatureEnabled('featureB' as keyof ExperimentalFeatures); + + expect(result).toBeFalsy(); + }); +}); From de7c0452a2379b4a7fe2f02b03f12ca7463e4b1d Mon Sep 17 00:00:00 2001 From: David Sanchez Soler Date: Wed, 24 Mar 2021 09:56:03 +0100 Subject: [PATCH 21/31] Hides horizontal bar with feature flag --- .../components/create_trusted_app_form.tsx | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx index 19b58463bac13..962c42b4da6b4 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx @@ -503,19 +503,20 @@ export const CreateTrustedAppForm = memo( /> - - {isTrustedAppsByPolicyEnabled ? ( - - - + <> + + + + + ) : null} ); From 9599891fd736f809b78b5cb5fb32358e2aa5e594 Mon Sep 17 00:00:00 2001 From: David Sanchez Soler Date: Wed, 24 Mar 2021 10:00:55 +0100 Subject: [PATCH 22/31] Compress text area depending on feature flag --- .../trusted_apps/view/components/create_trusted_app_form.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx index 962c42b4da6b4..81319f02158d4 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx @@ -497,7 +497,7 @@ export const CreateTrustedAppForm = memo( value={trustedApp.description} onChange={handleDomChangeEvents} fullWidth - compressed + compressed={isTrustedAppsByPolicyEnabled ? true : false} maxLength={256} data-test-subj={getTestId('descriptionField')} /> From 722da21b8240f6f2c848ffb5531a379eda618cfe Mon Sep 17 00:00:00 2001 From: David Sanchez Soler Date: Wed, 24 Mar 2021 11:12:56 +0100 Subject: [PATCH 23/31] Fixes failing test because feature flag --- .../pages/trusted_apps/view/trusted_apps_page.test.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx index c7a4d2864f4b8..1d07d9d894bab 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx @@ -33,11 +33,16 @@ import { isFailedResourceState, isLoadedResourceState } from '../state'; import { forceHTMLElementOffsetWidth } from './components/effected_policy_select/test_utils'; import { resolvePathVariables } from '../service/utils'; import { toUpdateTrustedApp } from '../../../../../common/endpoint/service/trusted_apps/to_update_trusted_app'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ htmlIdGenerator: () => () => 'mockId', })); +// TODO: remove this mock when feature flag is removed +jest.mock('../../../../common/hooks/use_experimental_features'); +const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock; + describe('When on the Trusted Apps Page', () => { const expectedAboutInfo = 'Add a trusted application to improve performance or alleviate conflicts with other applications running on your hosts. Trusted applications will be applied to hosts running Endpoint Security.'; @@ -477,6 +482,7 @@ describe('When on the Trusted Apps Page', () => { }); it('should have list of policies populated', async () => { + useIsExperimentalFeatureEnabledMock.mockReturnValue(true); const resetEnv = forceHTMLElementOffsetWidth(); const { getByTestId } = await renderAndClickAddButton(); expect(getByTestId('policy-abc123')); From a2c8e631c6e412e4f4b612fd3158054f943cc0d8 Mon Sep 17 00:00:00 2001 From: David Sanchez Soler Date: Wed, 24 Mar 2021 11:34:36 +0100 Subject: [PATCH 24/31] Fixes wrong import and unit test --- .../view/components/create_trusted_app_form.test.tsx | 3 +++ .../trusted_apps/view/components/create_trusted_app_form.tsx | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx index 201b387440439..5e37f19ab7074 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx @@ -366,11 +366,14 @@ describe('When using the Trusted App Form', () => { it('should validate multiple errors in form', () => { const andButton = getConditionBuilderAndButton(); + reactTestingLibrary.act(() => { fireEvent.click(andButton, { button: 1 }); }); + rerenderWithLatestTrustedApp(); setTextFieldValue(getConditionValue(getCondition()), 'someHASH'); + rerenderWithLatestTrustedApp(); expect(renderResult.getByText('[1] Invalid hash value')); expect(renderResult.getByText('[2] Field entry must have a value')); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx index 81319f02158d4..e03c2aad7621e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx @@ -25,7 +25,7 @@ import { NewTrustedApp, OperatingSystem, } from '../../../../../../common/endpoint/types'; -import { isValidHash } from '../../../../../../common/endpoint/validation/trusted_apps'; +import { isValidHash } from '../../../../../../common/endpoint/service/trusted_apps/validations'; import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; import { From d0d92d2e81e2af8e60f35288201555fe53f42759 Mon Sep 17 00:00:00 2001 From: David Sanchez Soler Date: Thu, 25 Mar 2021 09:47:11 +0100 Subject: [PATCH 25/31] Thwrows error if invalid feature flag check --- .../common/hooks/use_experimental_features.ts | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.ts b/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.ts index 43ccf4b291a7a..247b7624914cf 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.ts @@ -7,10 +7,22 @@ import { useSelector } from 'react-redux'; import { State } from '../../common/store'; -import { ExperimentalFeatures } from '../../../common/experimental_features'; +import { + ExperimentalFeatures, + getExperimentalAllowedValues, +} from '../../../common/experimental_features'; + +const allowedExperimentalValues = getExperimentalAllowedValues(); export const useIsExperimentalFeatureEnabled = (feature: keyof ExperimentalFeatures): boolean => { - return useSelector(({ app: { enableExperimental } }: State) => - enableExperimental ? enableExperimental[feature] : false - ); + return useSelector(({ app: { enableExperimental } }: State) => { + if (!enableExperimental || !(feature in enableExperimental)) { + throw new Error( + `Invalid enable value ${feature}. Allowed values are: ${allowedExperimentalValues.join( + ', ' + )}` + ); + } + return enableExperimental[feature]; + }); }; From 78e8a44afd20eb0af6e210aea50ec8bf4e014016 Mon Sep 17 00:00:00 2001 From: David Sanchez Soler Date: Thu, 25 Mar 2021 10:03:02 +0100 Subject: [PATCH 26/31] Adds snapshoot checks with feature flag enabled/disabled --- .../trusted_apps/view/trusted_apps_page.test.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx index 1d07d9d894bab..1acb12b71079d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx @@ -710,6 +710,19 @@ describe('When on the Trusted Apps Page', () => { expect(flyoutAddButton.disabled).toBe(true); }); }); + + describe('and there is a feature flag for agents policy', () => { + it('should hide agents policy if feature flag is disabled', async () => { + useIsExperimentalFeatureEnabledMock.mockReturnValue(false); + const renderResult = await renderAndClickAddButton(); + expect(renderResult).toMatchSnapshot(); + }); + it('should display agents policy if feature flag is enabled', async () => { + useIsExperimentalFeatureEnabledMock.mockReturnValue(true); + const renderResult = await renderAndClickAddButton(); + expect(renderResult).toMatchSnapshot(); + }); + }); }); describe('and there are no trusted apps', () => { From f6b1f41af1423d93e9838393780f829fd35de20f Mon Sep 17 00:00:00 2001 From: David Sanchez Soler Date: Thu, 25 Mar 2021 10:03:27 +0100 Subject: [PATCH 27/31] Test snapshots --- .../trusted_apps_page.test.tsx.snap | 5573 +++++++++++++++++ 1 file changed, 5573 insertions(+) create mode 100644 x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_page.test.tsx.snap diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_page.test.tsx.snap b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_page.test.tsx.snap new file mode 100644 index 0000000000000..35fc520558d6a --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_page.test.tsx.snap @@ -0,0 +1,5573 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`When on the Trusted Apps Page and the Add Trusted App button is clicked and there is a feature flag for agents policy should display agents policy if feature flag is enabled 1`] = ` +Object { + "asFragment": [Function], + "baseElement": + .c0 { + padding: 24px; +} + +.c0.siemWrapperPage--fullHeight { + height: 100%; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-flex: 1 1 auto; + -ms-flex: 1 1 auto; + flex: 1 1 auto; +} + +.c0.siemWrapperPage--noPadding { + padding: 0; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-flex: 1 1 auto; + -ms-flex: 1 1 auto; + flex: 1 1 auto; +} + +.c0.siemWrapperPage--withTimeline { + padding-bottom: 70px; +} + +.c3 { + margin-top: 8px; +} + +.c3 .siemSubtitle__item { + color: #6a717d; + font-size: 12px; + line-height: 1.5; +} + +.c1 { + margin-bottom: 24px; +} + +.c2 { + display: block; +} + +.c4 .euiFlyout { + z-index: 4001; +} + +.c5 .and-badge { + padding-top: 20px; + padding-bottom: calc(32px + (8px * 2) + 3px); +} + +.c5 .group-entries { + margin-bottom: 8px; +} + +.c5 .group-entries > * { + margin-bottom: 8px; +} + +.c5 .group-entries > *:last-child { + margin-bottom: 0; +} + +.c5 .and-button { + min-width: 95px; +} + +.c6 .policy-name .euiSelectableListItem__text { + -webkit-text-decoration: none !important; + text-decoration: none !important; + color: #343741 !important; +} + +.c7 { + background-color: #f5f7fa; + padding: 16px; +} + +.c10 { + padding: 16px; +} + +.c8.c8.c8 { + width: 40%; +} + +.c9.c9.c9 { + width: 60%; +} + +@media only screen and (min-width:575px) { + .c3 .siemSubtitle__item { + display: inline-block; + margin-right: 16px; + } + + .c3 .siemSubtitle__item:last-child { + margin-right: 0; + } +} + +
+
+
+
+
+

+ Trusted Applications +

+
+

+ Add a trusted application to improve performance or alleviate conflicts with other applications running on your hosts. Trusted applications will be applied to hosts running Endpoint Security. +

+
+
+
+ +
+
+
+
+ + +
+
+
+
+